Forms
A form is a Card (or a plain section), a heading, a Grid or Stack of Fields, and one primary action at the end. Submit it once with an invalid email to see validation, then with an address ending in @fail.test to see the server error.
<form onSubmit={onSubmit} noValidate aria-busy={busy}>
<Stack gap={6}>
{status === "error" ? <Alert tone="danger" title="We could not send your request" live="assertive">…</Alert> : null}
<Grid columns={2} gap={4} minItemWidth={200}>
<Field label="Full name" required error={errors.name}><Input name="name" autoComplete="name" /></Field>
<Field label="Work email" required error={errors.email}><Input name="email" type="email" /></Field>
</Grid>
<Field label="What are you building?" description="Optional."><Textarea name="notes" rows={3} /></Field>
<Separator />
<Stack direction="row" gap={2} justify="end">
<Button type="reset" variant="ghost">Clear</Button>
<Button type="submit" variant="primary" loading={busy}>Send request</Button>
</Stack>
</Stack>
</form>Layout
- One column by default. Put two short, related fields side by side (first and last name, city and postcode) with
Grid columns={2} minItemWidth={200}— it drops to one column when there is no room. - Labels above controls, always visible. Placeholders are examples, never labels.
gap={6}between groups,gap={4}between fields. ASeparatorbefore the actions marks the end of the form.- Actions at the end, aligned to the end. Primary on the outside, secondary (ghost) next to it. A form has one primary button.
- Mark required fields with
requiredonField; if most fields are optional, mark the required ones and say so once at the top instead.
Validation timing
| When | What |
|---|---|
| While typing | Nothing. Do not flag a field the user has not finished. |
| On blur | Validate the field that was left, if it has a value. |
| On submit | Validate everything, show every error, move focus to the first invalid field. |
| After an error | Re-validate that field on each change so the message clears as soon as it is fixed. |
Field's error prop does the ARIA wiring: aria-invalid on the control and the message in aria-describedby. Moving focus to the first invalid control lets screen reader users hear the error immediately:
event.currentTarget.querySelector<HTMLElement>("[aria-invalid='true']")?.focus();Use noValidate on the form so the browser's own bubbles do not compete with Field's messages, but keep type="email", autoComplete and inputMode — they drive mobile keyboards and autofill.
Submit states
- Submitting.
Button loadingkeeps its width, shows a spinner, setsaria-busyand ignores clicks. Disable the fields too, and setaria-busyon the form. - Server error. An
Alert tone="danger"at the top of the form, withlive="assertive". Keep the user's input. Say what happened and whether anything was saved. - Success. Either navigate away, or show an
Alert tone="success"in place and reset. For small saves (settings), a toast is enough — see Settings pages.
Error messages
Say what to do, not what went wrong: "Enter a valid work email", not "Invalid input". Keep them to one line. Never clear a field because it was invalid.
With a form library
The same layout works with React Hook Form or Zod — see React Hook Form and Validation with Zod.