Skip to content

Form

Fields are solved. Forms are not — the layout, the validation timing and the number of questions are what decide whether anyone finishes.

Also called Fieldset, Wizard, Multi-step Form — in this system all of them are Form.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground

Type one character into Email. On keystroke it is wrong before you have finished; on blur it waits until you have; on submit it makes you find the problem yourself.

Helps us skip the parts you already know.

One column, with deliberate exceptions

A single column has one path through it. Pair fields only when they are one piece of information — expiry and CVC, city and postcode.

One path down
A decision at every row

Multi-step

Split when the form has natural chapters, not to hide its length. Show where the user is, how much is left, and let them go back without losing anything.

Step 2 of 4 · Workspaceabout 90s left
AccountWorkspaceTeamBilling
WorkspaceFour short steps beats one long form.
app.co/

You can change this later.

Settings — no submit button

Settings are independent switches, not a transaction. Each one commits on change and confirms in place.

NotificationsSaved
A summary of everything that changed, on Monday morning.
Email me when someone @-mentions me.

No Save button. Each control commits on change and confirms in place — a settings page with a Save button at the bottom is a page people leave without pressing it.

Checkout

The highest-stakes form there is. Every field has an autocomplete attribute, the numeric fields raise a numeric keypad, and the button says what it will cost.

Plan
Payment

Three digits on the back.

The button says what it costs. “Submit” at the end of a checkout is where people stop.

The error summary

On submit failure, a summary at the top linking to each bad field. It is the only pattern that works when the errors are below the fold, and it is what a screen reader needs.

Interaction states

Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.

Empty
Focused
Error
Success
Disabled
Submitting
Submitted
2 fields to fix
Form error

Anatomy

Every part, every measurement, and the reason it is that number.

Your detailsWe only ask for what we need to bill you.

Receipts and password resets go here.

One group, one column, one exception. The paired city/postcode row is the only place the eye is asked to go sideways, and those two fields are one address.

  1. Form width30–36rem

    Wide enough for a real value, narrow enough that the label, the field and the error stay in one eye span. A field stretched across a 1440px window is harder to use, not easier.

  2. Legend--text-h4

    A real <legend> in a real <fieldset>. It is announced before every control in the group, which is how each field inherits its context for free.

  3. Field gap16px

    The rhythm inside a group. It has to be clearly larger than the 6px label-to-input gap, or the labels start looking attached to the field above.

  4. Group gap24–32px

    Roughly double the field gap. Proximity is the only grouping signal that works without a border, and it works better than one.

  5. Label13px, 6px above

    Above the field, always. Never inside it — a placeholder disappears at the exact moment the user needs it.

  6. Description12px, muted, under the label

    Static guidance, always visible. It must not vanish when an error appears; the error stacks under it rather than replacing it.

  7. Error message12px danger, aria-live

    Says what is wrong and what to do. "Invalid input" is an error message that helps nobody.

  8. Optional marker“Optional”, not “*”

    Mark whichever set is smaller. Most forms are mostly required, so marking the optional ones is less ink and less ambiguity — an asterisk means nothing without a legend.

  9. ActionsRight-aligned, primary last

    Primary in the bottom right, where the eye finishes. Cancel is a text button — giving it equal weight makes abandoning the form look like an equally good idea.

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed for
--ds-fg-secondary—Labels
--ds-fg-muted—Descriptions and counters
--ds-danger-text—Error messages
--ds-danger-border—Invalid control border
--ds-success-text—Save confirmation

Spacing

TokenValueUsed for
label gapLabel to control
field gapBetween fields
group gapBetween fieldsets
max-widthForm measure

Typography

TokenValueUsed for
--text-labelField labels
--text-captionHints and errors

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightLabel gapMin widthMax widthTouch targetWhen to use
Form width———30–36rem—One column. The whole field stays in one eye span.
Wide form———48rem—Only when paired rows genuinely need it — address, card details.
Label → control—6px———Close enough to be owned by the field.
Field → field—16px———Must be clearly larger than the label gap.
Group → group—24–32px———Roughly double the field gap.
Control height36px (md) / 44px (lg)———44pxlg on touch and for the primary action.
Actions row40px10px———Right-aligned, primary last.
Inline pair——9rem each——Below this, stack them — a 5rem field looks broken.

Do

blur → show · then every keystroke → clear when valid
Validate on blur, then re-validate on keystrokeWaiting until the user leaves a field means never being wrong before they have finished. Clearing the error as they fix it means the correction is confirmed the instant it happens.
autocomplete="email" · "cc-number" · "postal-code"
Set autocomplete on every fieldIt is the single highest-leverage attribute in a form. A browser filling six fields in one tap converts better than any redesign, and it is one attribute per field.
not “Submit”
Say what the button does"Pay $40" and "Create account" tell the user what happens next. "Submit" tells them nothing at the exact moment they are deciding whether to trust you.
Company · Optional
Mark the smaller setIf most fields are required, mark the optional ones. An asterisk is a convention that needs a legend to explain it; the word "Optional" needs nothing.
submit → summary announced → focus first invalid field
Move focus to the first error on submitOtherwise the user presses the button and nothing appears to happen — the errors are above them, off screen. Focus the first bad field and announce the summary.
inputMode="numeric" autoComplete="cc-number"
Keep the input type honestinputMode="numeric" raises a keypad instead of a keyboard on a phone. On a card form that is the difference between four taps and forty.

Don't

Enter a valid email
Do not validate on every keystrokeThe user types "a" and the form says their email is invalid. It is technically correct and completely useless — the form is wrong more often than they are.
why?
Do not disable the submit buttonA greyed-out button with no explanation is a dead end: nothing says which field is the problem. Leave it enabled, and let pressing it produce the answer.
Email address
Do not use the placeholder as the labelIt vanishes the moment the user starts typing — precisely when they need to check what they were asked. It also fails contrast in almost every implementation.
one bad postcode → whole form empty
Do not clear the form on errorWiping a password, a card number or a long message because one field failed is the fastest way to lose someone who was ready to finish.
123for six fields
Do not split a short form into stepsA wizard adds ceremony, state and a back button. Six fields do not need any of it — steps are for chapters, not for hiding length.
saves itself
Do not put a Save button on a settings pageSettings are independent switches, not a transaction. Users flip one and leave, and a page that needed a Save discards their change silently.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

1.3.1Info and RelationshipsA1.3.5Identify Input PurposeAA3.3.1Error IdentificationA3.3.2Labels or InstructionsA3.3.3Error SuggestionAA3.3.4Error PreventionAA

Contrast

  • Error text at 12px must reach 4.5:1. Red on white is a common failure — --ds-danger-text is the corrected value, not the raw ramp colour.
  • The invalid state must not be carried by the border colour alone. Ours changes the border and adds a message with an icon, so it survives greyscale and colour blindness.
  • Placeholder text is exempt from contrast rules only because it is not content. That is an argument for never putting content in it.

Keyboard

TabMoves to the next control in DOM order. That order must match the visual order — a two-column layout is the usual place this breaks.
EnterSubmits, from any single-line input. A form that only submits from the button breaks a reflex everyone has.
SpaceToggles checkboxes, radios and switches.
↑ ↓Moves within a radio group — the group is one tab stop, not one per option.
EscapeCloses an open dropdown without leaving the field.

Screen readers

  • Each field announces as "Email, edit text, required, receipts and password resets go here" — label, role, state, then description.
  • The error must be in the accessibility tree, not only in colour. aria-describedby plus aria-invalid does this; a red border alone does not.
  • Announce the summary on submit failure with role="alert", then move focus. Doing both at once means the announcement is cut off by the focus change.
  • Do not use aria-live on a field that validates per keystroke — it produces a stream of interruptions as the user types.
  • Group related controls in a real fieldset. Without it, a screen-reader user hears eight unrelated radio buttons.

Focus & touch

  • Focus is visible on every control, never removed, and never moved while the user is typing. On submit failure it goes to the first invalid field — one deliberate move, after the summary has been announced. Auto-focusing the first field on page load is acceptable on a dedicated form page and hostile anywhere else, because it scrolls the page for anyone who arrived to read.
  • Controls are 44px tall on touch. Labels are click targets, which matters most for checkboxes and radios. inputMode raises the right keyboard — numeric for cards and codes, email for addresses — and the submit button is full-width, because a right-aligned button on a phone is in the corner the thumb reaches last.
AttributeApplied toNotes
<label for>Every controlA real label, not a styled span. It also makes the label a click target, which doubles the size of every checkbox.
aria-describedbyControls with a hint or errorPoints at both the description and the message. Announced after the label, so the user hears the field, then the guidance, then the problem.
aria-invalidFailing controlsThe programmatic half of the error state. The red border is the other half, and neither is sufficient alone.
role="alert"The error summaryAnnounces the summary on submit failure without moving focus. Then move focus deliberately to the first invalid field.
<fieldset> / <legend>Each groupThe legend is announced before every control in the group. This is how a radio group gets a name, and there is no ARIA substitute worth using.
autocompleteEvery field with a standard purposeWCAG 1.3.5 requires it. It is also the single biggest completion-rate win available.

Code

Example usage

tsx
1import { Field, FieldRow, Fieldset, TextInput } from '@/ui/Input'23<form onSubmit={handleSubmit} noValidate>4  <Fieldset legend="Your details" description="We only ask for what we need to bill you.">5    <Field label="Full name" required>6      <TextInput autoComplete="name" {...register('name')} />7    </Field>89    <Field10      label="Email"11      description="Receipts and password resets go here."12      status={showError('email') ? 'error' : 'default'}13      message={showError('email') ? errors.email : undefined}14      required15    >16      <TextInput type="email" inputMode="email" autoComplete="email" {...register('email')} />17    </Field>1819    {/* Pair only what is genuinely one piece of information */}20    <FieldRow cols={2}>21      <Field label="City" optional>22        <TextInput autoComplete="address-level2" {...register('city')} />23      </Field>24      <Field label="Postcode" optional>25        <TextInput autoComplete="postal-code" {...register('postcode')} />26      </Field>27    </FieldRow>28  </Fieldset>2930  <Button type="submit">Save details</Button>31</form>3233// Strict on the way in, forgiving on the way out.34// Show an error once the user has left the field; from then on,35// re-check every keystroke so the fix is confirmed immediately.36const showError = (k: string) =>37  !!errors[k] && (touched[k] || submitted)3839// On failure: announce, then move. Doing both at once cuts the40// announcement off mid-sentence.41function handleSubmit(e: FormEvent) {42  e.preventDefault()43  setSubmitted(true)44  const first = Object.keys(errors)[0]45  if (!first) return save()46  setSummaryVisible(true)47  requestAnimationFrame(() => document.getElementById(first)?.focus())48}

Framework-free HTML

Framework-free. Every relationship here is a real HTML relationship.

html
<form novalidate>
  <fieldset>
    <legend>Your details</legend>

    <div class="ds-field">
      <label for="email">Email <span class="ds-field__req">*</span></label>
      <p id="email-desc" class="ds-field__desc">Receipts and password resets go here.</p>
      <input
        id="email"
        name="email"
        type="email"
        inputmode="email"
        autocomplete="email"
        required
        aria-describedby="email-desc email-err"
        aria-invalid="true"
      />
      <!-- Stacks under the description; it never replaces it -->
      <p id="email-err" class="ds-field__err">
        That does not look like an email address.
      </p>
    </div>
  </fieldset>

  <!-- Announced on submit failure, before focus moves -->
  <div role="alert" class="ds-form__summary">
    <h2>Two fields need attention</h2>
    <ul><li><a href="#email">Email — that does not look like an email address</a></li></ul>
  </div>

  <button type="submit">Save details</button>
</form>

CSS

css
.ds-form {
  /* One eye span. A field stretched across 1440px is harder to
     use, not easier. */
  max-inline-size: 34rem;
  display: flex;
  flex-direction: column;
  gap: 32px;              /* between groups */
}

.ds-form fieldset {
  display: flex;
  flex-direction: column;
  gap: 16px;              /* between fields — must clearly beat the 6px
                             label gap, or labels look attached upward */
  border: 0;
  padding: 0;
  margin: 0;
}

.ds-field { display: flex; flex-direction: column; gap: 6px; }

.ds-field__desc { font: var(--text-caption); color: var(--ds-fg-muted); }
.ds-field__err  { font: var(--text-caption); color: var(--ds-danger-text); }

/* Two signals, so the state survives greyscale */
.ds-field input[aria-invalid='true'] {
  border-color: var(--ds-danger-border);
}

/* Paired fields, and only where they are one piece of information.
   Below 9rem each they stack — a 5rem field looks broken. */
.ds-field-row {
  display: grid;
  gap: 16px;
}
@media (min-width: 40rem) {
  .ds-field-row { grid-template-columns: repeat(2, 1fr); }
}

/* On a phone the primary action is full width — a right-aligned
   button is in the corner the thumb reaches last. */
.ds-form__actions {
  display: flex;
  justify-content: flex-end;
  gap: 10px;
}
@media (max-width: 39.99rem) {
  .ds-form__actions { flex-direction: column-reverse; }
  .ds-form__actions button { inline-size: 100%; }
}

Component API

Field

PropTypeDefaultDescription
labelstring—Rendered above the control and wired to it with a real <label for>.
descriptionstring—Static guidance. Always visible — it is not replaced by an error.
messagestring—Validation message. Stacks under the description.
status'default' | 'error' | 'success' | 'warning''default'Colours the message and drives aria-invalid.
requiredboolean—Marks the field required. Use this or `optional`, whichever set is smaller.
optionalboolean—Renders “Optional” instead of an asterisk — the better choice when most fields are required.
counter{ value, max }—Character count for a length-limited field.
hideLabelboolean—Visually hides the label but keeps it for assistive tech. Never simply omit the label.

FieldRow

PropTypeDefaultDescription
cols1 | 2 | 32Fields per row above the small breakpoint. Stacks below it.
children*ReactNode—The fields to pair. Pair only what is genuinely one piece of information.

Fieldset

PropTypeDefaultDescription
legend*string—Announced before every control in the group. This is how a radio group gets its name.
descriptionstring—One line under the legend.
children*ReactNode—Five to seven fields. Past that, split the group.

Notes

Professional tips

  • Count the fields, then remove one. The question you cannot justify is costing you completions, and no styling recovers it.
  • Order fields by how easy they are to answer. Name and email first builds momentum; asking for a VAT number first loses people who would have finished.
  • Save drafts on any form longer than about six fields. Recovering a half-finished form after a lost connection converts far better than starting again.
  • Show what the format should be before it fails: "MM / YY" as a placeholder beats "Invalid date" as an error.
  • Never reject a phone number, postcode or name for its format alone. Normalise what you can, and accept the rest — those validators reject real people constantly.
  • One primary action per form. Two buttons of equal weight at the bottom is a decision the user should not have to make.

Performance

  • Keep field state uncontrolled where you can. Re-rendering a thirty-field form on every keystroke is the usual cause of typing lag in a checkout.
  • Debounce anything that hits the network — username availability, address lookup — at around 300ms, and never block typing on it.
  • Do not validate the whole form on every change. Validate the field that changed, and the whole form only on submit.
  • Autofill fires change events for many fields at once. Batch the resulting validation, or the form flashes errors during a browser fill.

Common mistakes

  • Validating on keystroke, so the form is wrong before the user is.
  • A disabled submit button with no indication of what is missing.
  • Placeholder text used as the label.
  • Two columns for fields that are not related, splitting the eye path and orphaning the right-hand column.
  • Clearing entered data — especially passwords and card numbers — after a failed submit.
  • A settings page with a Save button nobody presses.
  • Missing autocomplete attributes, which is both a WCAG failure and a measurable conversion loss.
  • Error messages that say what is wrong but not what to do about it.

Real-world recommendations

  • Instrument per-field drop-off, not just form-level completion. The abandonment is nearly always concentrated on one or two fields, and you cannot see which without the data.
  • Watch how long each field takes to fill. A field that takes noticeably longer than its neighbours is usually asking for something the user has to go and look up.
  • Test with autofill on. A form that looks correct empty and breaks when the browser fills it is a bug most teams never see, because they always type.
  • The best form change is usually a deletion. Removing one field beats redesigning the other eight.
  • On a settings page, the confirmation matters more than the mechanism. Users need to see that the change stuck — an inline "Saved" beats a toast, which they may be looking away from.