- Design system
- Forms
05 — Forms
Forms
Inputs, selects, choices and validation. Every field has a real label, every control clears 44 px, and every error is announced — not just coloured.
<label for>. Use .vh to hide one visually if the layout truly implies it — never delete it.
Text fields
One shared shape for input, textarea and select. Wrap each in .field, which stacks label, control and hint with the right gaps.
We only use this to address the proposal.
A reply lands within one business day.
<div class="field">
<label class="label" for="email">Work email</label>
<input class="input" id="email" type="email"
inputmode="email" autocomplete="email"
aria-describedby="email-hint">
<p class="hint" id="email-hint">A reply lands within one business day.</p>
</div>
Wire the hint with aria-describedby or a screen reader never reads it. Set inputmode and autocomplete — they are what give a phone the right keyboard and a one-tap fill.
Textareas resize vertically only — horizontal resize breaks the layout. .textarea--code switches to the mono stack for anything the user will paste rather than write.
The group holds the border and the focus state; the input inside is borderless. That is what keeps the ring around the whole control rather than half of it.
Validation
The hook is aria-invalid="true", not a class. The same attribute a screen reader uses to say "invalid entry" is the one that turns the field red, so the two can never drift apart.
Enter a full email address, including the domain.
<input class="input" id="email" type="email"
aria-invalid="true" aria-describedby="email-err">
<p class="error" id="email-err">Enter a full email address.</p>
Say what to do, not what went wrong. "Enter a full email address" beats "Invalid input". Move focus to the first invalid field on submit, and never clear what the user typed.
Checkboxes and radios
The control keeps its native accent-color, so it inherits platform focus, forced-colors and high-contrast behaviour for free. What we style is the 44 px row around it.
Group related choices in a <fieldset> with a <legend>. Without it a screen reader reads three options with no idea what question they answer.
The selected state uses :has(input:checked) — no JavaScript, and the styling can never fall out of sync with the actual checked state.
Switches
A switch applies immediately. A checkbox applies on submit. Choosing the wrong one is the most common form mistake, and users notice: they will wait for a Save button that never comes.
<label class="switch"> <input type="checkbox" checked> <span class="switch__track"></span> <span class="sm">Auto-deploy on merge to main</span> </label>
The input is visually hidden but still focusable and still in the tab order — that is what makes the switch keyboard-operable with Space.
Range and file
Anything below this goes to the human review queue.
Form layout
One column by default — it is faster to complete and never ambiguous about reading order. Split to two only for genuinely paired fields (first/last name, city/postcode).
Primary action first in the DOM, and it is the one the Enter key hits. .form-actions wraps on a narrow screen rather than squeezing the buttons.