Forms
Forms aren’t decorative. Get the label right, the helper right, the error right – the rest is restraint. Every control on the site composes from a small set of primitives bound to the same tokens; the work is making each field obvious and the form as a whole easy to finish.
Anatomy
Five parts. The bottom three are optional. Every Field carries label, control, and – when the moment calls for it – helper text, an error, and a required marker. The wiring is <label for> to control id, aria-describedby to helper or error, aria-required and aria-invalid for state.
- 1Label – always visible, sentence case. Required marker is a single asterisk in the danger token; “(optional)” is the inverse mark.
- 2Control – one of six primitives. 40px target, 1px border, 4px radius, teal focus ring.
- 3Helper – one line. Formatting hints, privacy notes. Replaced by error text when the field is invalid.
- 4Error – single line, plain, owns the problem. Linked to the control via
aria-describedby. - 5Required marker – visual asterisk is decorative; the real signal is
aria-required=“true”.
Field controls
Three text primitives plus a select. Default height is 40px. Compact forms (settings rows, table inline edits) use 32px. Long-form composition fields use 48px. Padding is 10px / 14px, border is 1px, radius is --es-radius-control (4px). The focus ring is the universal teal pair from the states foundation – never a custom colour.
Control states
Six surfaces. Hover lifts the border to --es-color-stone-500; focus pairs the teal border with the universal focus ring; error reuses the danger token; disabled drops to the sunken surface; read-only sits on cream-tinted stone-50 and stays selectable.
Field meta
Label, helper, error, and required marker. They’re separate jobs and the page treats them that way. A field never shows helper and error at the same time – the error replaces the helper for the duration of the problem.
Selection controls
Checkbox, radio, and switch. All three have real keyboard support, real focus rings, and the same ticked-teal accent. Use checkbox for independent options, radio for one-of-many, switch for a setting that takes effect immediately.
Field groups
Related fields belong to a <fieldset> with a visible <legend>. The legend acts as the group’s label – screen readers announce it with each control. Vertical rhythm between fields uses --es-spacing-stack-md (16px). Tightly related fields – the parts of a name, the lines of an address – tighten to --es-spacing-stack-sm (12px).
Form layouts
Single column, always. The eye reads top-to-bottom; the label sits directly above the control; the control runs the full width of the form column. Two columns are reserved for genuinely paired fields – first and last name, city and postcode – and only at the --es-bp-md breakpoint and above. On mobile, everything stacks.
Labels above. Controls full-width. The eye runs straight down the form and finishes faster.
Side-labelled forms force the eye to ping-pong. They look tidy in mockups and read poorly in use.
Validation
Validate on submit by default. For high-cost fields – email, billing details, anything that hits a paid API – validate on blur, after the first interaction. Never validate on every keystroke; that’s not feedback, that’s nagging.
A specimen form
The membership sign-up. Real fields, real labels, real focus rings – everything composes from the primitives above.
Usage rules
Labels stay visible. The placeholder describes format, not purpose.
Placeholders disappear the moment someone types. The label needs to stay.
Error sits with the field that owns it. The eye finds it on the way down.
Top-of-form banners hide which field is wrong. Reserve them for server errors.
Primary on the right. Cancel as a link on the left – never a competing button.
Two outline buttons make the user choose between equals. They aren’t equals.
Most fields on a form are required. Mark the few that aren’t.
A column of asterisks is noise. If everything is required, mark nothing.
The error replaces the helper while the field is invalid. One line, one job.
A disabled submit hides what’s wrong. Let them press it; show the errors; move focus to the first one.
Tokens to ship
Every value above resolves to an existing token. The component layer never reaches into a ramp directly.