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.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
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.
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.
Settings — no submit button
Settings are independent switches, not a transaction. Each one commits on change and confirms in place.
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.
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.
Two fields need attention
That does not look like an email address.
We could not read this card.
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.
Every part, every measurement, and the reason it is that number.
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.
- 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.
- 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.
- 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.
- 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.
- Label13px, 6px above
Above the field, always. Never inside it — a placeholder disappears at the exact moment the user needs it.
- 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.
- Error message12px danger, aria-live
Says what is wrong and what to do. "Invalid input" is an error message that helps nobody.
- 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.
- 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.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used 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
| Token | Value | Used for |
|---|---|---|
| label gap | Label to control | |
| field gap | Between fields | |
| group gap | Between fieldsets | |
| max-width | Form measure |
Typography
| Token | Value | Used for |
|---|---|---|
| --text-label | Field labels | |
| --text-caption | Hints and errors |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Label gap | Min width | Max width | Touch target | When 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 height | 36px (md) / 44px (lg) | — | — | — | 44px | lg on touch and for the primary action. |
| Actions row | 40px | 10px | — | — | — | Right-aligned, primary last. |
| Inline pair | — | — | 9rem each | — | — | Below this, stack them — a 5rem field looks broken. |
autocomplete="email" · "cc-number" · "postal-code"inputMode="numeric" autoComplete="cc-number"Not a checklist to run at the end. These are the requirements the component was built from.
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
| Tab | Moves to the next control in DOM order. That order must match the visual order — a two-column layout is the usual place this breaks. |
| Enter | Submits, from any single-line input. A form that only submits from the button breaks a reflex everyone has. |
| Space | Toggles checkboxes, radios and switches. |
| ↑ ↓ | Moves within a radio group — the group is one tab stop, not one per option. |
| Escape | Closes 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.
| Attribute | Applied to | Notes |
|---|---|---|
| <label for> | Every control | A real label, not a styled span. It also makes the label a click target, which doubles the size of every checkbox. |
| aria-describedby | Controls with a hint or error | Points at both the description and the message. Announced after the label, so the user hears the field, then the guidance, then the problem. |
| aria-invalid | Failing controls | The programmatic half of the error state. The red border is the other half, and neither is sufficient alone. |
| role="alert" | The error summary | Announces the summary on submit failure without moving focus. Then move focus deliberately to the first invalid field. |
| <fieldset> / <legend> | Each group | The 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. |
| autocomplete | Every field with a standard purpose | WCAG 1.3.5 requires it. It is also the single biggest completion-rate win available. |
Example usage
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.
<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
.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
| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | — | Rendered above the control and wired to it with a real <label for>. |
| description | string | — | Static guidance. Always visible — it is not replaced by an error. |
| message | string | — | Validation message. Stacks under the description. |
| status | 'default' | 'error' | 'success' | 'warning' | 'default' | Colours the message and drives aria-invalid. |
| required | boolean | — | Marks the field required. Use this or `optional`, whichever set is smaller. |
| optional | boolean | — | Renders “Optional” instead of an asterisk — the better choice when most fields are required. |
| counter | { value, max } | — | Character count for a length-limited field. |
| hideLabel | boolean | — | Visually hides the label but keeps it for assistive tech. Never simply omit the label. |
FieldRow
| Prop | Type | Default | Description |
|---|---|---|---|
| cols | 1 | 2 | 3 | 2 | Fields 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
| Prop | Type | Default | Description |
|---|---|---|---|
| legend* | string | — | Announced before every control in the group. This is how a radio group gets its name. |
| description | string | — | One line under the legend. |
| children* | ReactNode | — | Five to seven fields. Past that, split the group. |
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.