Forms and validation
Labels, help text, required fields, inline errors and the order in which a form reveals them.
The problem#
A form is a conversation in which only one side can talk. Every missing label, early error or greyed-out button is a question the person can't ask.
Forms fail in familiar ways. A placeholder stands in for the label and vanishes on the first keystroke. Errors fire before anyone has finished typing. A submit button stays grey with no hint of what is missing. On a phone, the browser zooms into a 14px input and the layout jumps. In accounts payable, each of these costs real money: a mistyped routing number sends a payment to the wrong bank.
The solution in Canon#
Labels are always visible, help comes before the mistake, errors come after it and say how to fix it, and the submit button always answers.
Every field has a label
Validate late, clear early
Submit stays enabled
A supplier onboarding form
Press Add supplier with the form empty to see the error summary and inline errors. Fix a field and its error clears as you type. Switch to Check and the routing number goes away. Draft writes the description.
import { AIGenerateButton } from "@oration/canon/components/ai/ai-button";import { Alert, AlertDescription, AlertTitle } from "@oration/canon/components/alert";import { Button } from "@oration/canon/components/button";import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel, FieldLegend, FieldSet,} from "@oration/canon/components/field";import { Input } from "@oration/canon/components/input";import { Label } from "@oration/canon/components/label";import { RadioGroup, RadioGroupItem } from "@oration/canon/components/radio-group";import { SelectField } from "@oration/canon/components/select-field";import { Textarea } from "@oration/canon/components/textarea";import { toast } from "@oration/canon/components/toast";import { useShownErrors, useZodForm } from "@oration/canon/hooks/use-zod-form";import { text } from "@oration/canon/lib/form-controls";import { CircleAlertIcon } from "lucide-react";import * as React from "react";import { z } from "zod";export function SupplierOnboarding() { const methods = ["ach", "check", "card"] as const; const terms = ["15", "30", "45", "60"] as const; // Every message the form can show lives in the schema. const supplierSchema = z .object({ legalName: z .string() .trim() .min( 1, "Enter the supplier's legal name as it appears on their W-9.", ), dba: z.string(), ein: z .string() .trim() .regex( /^\d{2}-?\d{7}$/, "Enter a 9-digit EIN, like 12-3456789.", ), email: z .string() .trim() .regex( /^[^\s@]+@[^\s@]+\.[^\s@]+$/, "Enter an email address, like ap@northwindfreight.com.", ), method: z.enum(methods), routing: z.string(), terms: z.enum(terms), supplies: z.string(), }) .superRefine((v, ctx) => { // The routing number is only asked for, and only checked, for ACH. if (v.method === "ach" && !/^\d{9}$/.test(v.routing.trim())) ctx.addIssue({ code: "custom", path: ["routing"], message: "Enter the 9-digit routing number from a voided check or bank letter.", }); }); const blank: z.input<typeof supplierSchema> = { legalName: "", dba: "", ein: "", email: "", method: "ach", routing: "", terms: "30", supplies: "", }; const labels: Record<string, string> = { legalName: "Legal name", ein: "Tax ID (EIN)", email: "Remit-to email", routing: "Routing number", }; const id = React.useId(); const fieldId = (name: string) => `${id}-${name}`; const summaryRef = React.useRef<HTMLDivElement>(null); const [summary, setSummary] = React.useState<string[]>([]); const previousSupplies = React.useRef(""); // Blur reveals a filled field's error, submit reveals all of them, and an // error clears as soon as the fix is typed. New errors never appear // mid-keystroke. const form = useZodForm({ schema: supplierSchema, defaultValues: blank, visibility: { reveal: "blur-if-filled", clear: "fixed" }, onInvalid: (issues) => { setSummary(issues.map((issue) => issue.path)); requestAnimationFrame(() => summaryRef.current?.focus()); }, onSubmit: (values) => { setSummary([]); toast.add({ type: "success", title: `${values.legalName} added`, description: `We emailed ${values.email} to upload a signed W-9.`, }); form.reset(blank); }, }); // The summary lists what the last submit found; each entry leaves once its // field is fixed, and blur never adds one back. const shown = useShownErrors(form); const open = summary.filter((name) => shown[name]); if (open.length !== summary.length) setSummary(open); const describedBy = (name: string, hasHelp: boolean, error?: string) => [ hasHelp ? `${fieldId(name)}-help` : "", error ? `${fieldId(name)}-error` : "", ] .filter(Boolean) .join(" ") || undefined; return ( <form noValidate onSubmit={form.submit} aria-labelledby={`${id}-title`} className="flex w-full max-w-xl flex-col overflow-hidden rounded-xl bg-card text-left shadow-border" > <div className="flex flex-col gap-1 border-b border-border px-5 py-4"> <h3 id={`${id}-title`} className="text-sm font-semibold"> New supplier </h3> <p className="text-13 text-muted-foreground"> Everything except the optional fields is needed before Cedarline can pay this supplier. </p> </div> <div className="flex flex-col gap-5 px-5 py-5"> {summary.length > 0 ? ( <Alert ref={summaryRef} tabIndex={-1} variant="destructive" aria-labelledby={`${id}-summary`} className="outline-none focus-visible:ring-3 focus-visible:ring-destructive/20" > <CircleAlertIcon aria-hidden="true" /> <AlertTitle id={`${id}-summary`}> Fix{" "} {summary.length === 1 ? "1 field" : `${summary.length} fields`}{" "} to add this supplier </AlertTitle> <AlertDescription> <ul className="mt-1 flex flex-col gap-0.5"> {summary.map((name) => ( <li key={name}> <button type="button" onClick={() => document .getElementById( fieldId(name), ) ?.focus() } className="rounded-sm text-left text-[13px] underline underline-offset-3 outline-none focus-visible:ring-2 focus-visible:ring-ring/50" > {labels[name]}: {shown[name]} </button> </li> ))} </ul> </AlertDescription> </Alert> ) : null} <FieldGroup className="gap-5"> <form.Field name="legalName"> {(field) => { const legalName = text(field); return ( <Field> <FieldLabel htmlFor={fieldId("legalName")}> Legal name </FieldLabel> <Input id={fieldId("legalName")} autoComplete="organization" placeholder="Northwind Freight LLC" {...legalName.props} aria-describedby={describedBy( "legalName", false, legalName.error, )} /> <FieldError id={`${fieldId("legalName")}-error`} className="text-xs" > {legalName.error} </FieldError> </Field> ); }} </form.Field> <form.Field name="dba"> {(field) => ( <Field> <FieldLabel htmlFor={fieldId("dba")}> Doing business as <span className="font-normal text-muted-foreground"> (optional) </span> </FieldLabel> <Input id={fieldId("dba")} placeholder="Northwind" {...text(field).props} /> </Field> )} </form.Field> <div className="grid gap-5 sm:grid-cols-2"> <form.Field name="ein"> {(field) => { const ein = text(field); return ( <Field> <FieldLabel htmlFor={fieldId("ein")}> Tax ID (EIN) </FieldLabel> <Input id={fieldId("ein")} inputMode="numeric" placeholder="12-3456789" {...ein.props} aria-describedby={describedBy( "ein", true, ein.error, )} /> <FieldDescription id={`${fieldId("ein")}-help`} className="text-xs" > From box 3 of their W-9. </FieldDescription> <FieldError id={`${fieldId("ein")}-error`} className="text-xs" > {ein.error} </FieldError> </Field> ); }} </form.Field> <form.Field name="terms"> {(field) => ( <Field> <FieldLabel htmlFor={fieldId("terms")}> Payment terms </FieldLabel> <SelectField id={fieldId("terms")} {...select(field).props} options={[ { value: "15", label: "Net 15" }, { value: "30", label: "Net 30" }, { value: "45", label: "Net 45" }, { value: "60", label: "Net 60" }, ]} className="w-full" /> </Field> )} </form.Field> </div> <form.Field name="email"> {(field) => { const email = text(field); return ( <Field> <FieldLabel htmlFor={fieldId("email")}> Remit-to email </FieldLabel> <Input id={fieldId("email")} type="email" autoComplete="email" placeholder="ap@northwindfreight.com" {...email.props} aria-describedby={describedBy( "email", true, email.error, )} /> <FieldDescription id={`${fieldId("email")}-help`} className="text-xs" > Remittance advice goes here after each payment run. </FieldDescription> <FieldError id={`${fieldId("email")}-error`} className="text-xs" > {email.error} </FieldError> </Field> ); }} </form.Field> <form.Field name="method"> {(field) => ( <FieldSet className="gap-3"> <FieldLegend variant="label"> Payment method </FieldLegend> <RadioGroup {...radio(field).props} className="flex flex-wrap gap-x-5 gap-y-2" > {( [ ["ach", "ACH"], ["check", "Check"], ["card", "Virtual card"], ] as const ).map(([value, label]) => ( <div key={value} className="flex items-center gap-2" > <RadioGroupItem id={fieldId(`method-${value}`)} value={value} /> <Label htmlFor={fieldId( `method-${value}`, )} className="font-normal" > {label} </Label> </div> ))} </RadioGroup> </FieldSet> )} </form.Field> <form.Subscribe selector={(state) => state.values.method}> {(method) => method === "ach" ? ( <form.Field name="routing"> {(field) => { const routing = text(field); return ( <Field> <FieldLabel htmlFor={fieldId("routing")} > Routing number </FieldLabel> <Input id={fieldId("routing")} inputMode="numeric" placeholder="021000021" {...routing.props} aria-describedby={describedBy( "routing", false, routing.error, )} className="font-mono sm:w-56" /> <FieldError id={`${fieldId("routing")}-error`} className="text-xs" > {routing.error} </FieldError> </Field> ); }} </form.Field> ) : null } </form.Subscribe> <form.Field name="supplies"> {(field) => ( <Field> <div className="flex items-center justify-between gap-3"> <FieldLabel htmlFor={fieldId("supplies")}> What they supply <span className="font-normal text-muted-foreground"> (optional) </span> </FieldLabel> <AIGenerateButton label="Draft" onGenerate={() => `${form.state.values.legalName.trim() || "This supplier"} provides less-than-truckload freight between our Reno and Sacramento warehouses, billed per shipment against PO 4471.` } onResult={(draft) => { previousSupplies.current = field.state.value; field.handleChange(draft); }} onUndo={() => field.handleChange( previousSupplies.current, ) } /> </div> <Textarea id={fieldId("supplies")} {...text(field).props} aria-describedby={`${fieldId("supplies")}-help`} placeholder="Freight between the Reno and Sacramento warehouses" /> <FieldDescription id={`${fieldId("supplies")}-help`} className="text-xs" > Nora reads this when a supplier calls about a payment. </FieldDescription> </Field> )} </form.Field> </FieldGroup> </div> <div className="flex items-center justify-end gap-2 border-t border-border bg-muted/50 px-5 py-3"> <Button type="button" variant="ghost" onClick={() => { form.reset(blank); setSummary([]); }} > Clear form </Button> <Button type="submit">Add supplier</Button> </div> </form> );}Decision guide#
Which pieces to reach for. Most forms in Cedarline are a column of Fields; settings use label-left rows.
| You need | Use | Notes |
|---|---|---|
| A labelled input in a form or dialog | Field with FieldLabel, the control, FieldDescription and FieldError | Label above. The default. |
| A setting on a settings page | SettingsRow in a SettingsGroup (Settings section) | Label and description left, control right. Stacks below 640px. |
| A switch or checkbox with a consequence | A horizontal Field, or SettingsRow inline | The control sits beside its label at every width. |
| Two to four options to compare | Radio group in a FieldSet with a FieldLegend | Choice card when each option needs a description. |
| Five or more options | Select field, or Combobox past about 15 | Payment terms, currency, timezone. |
| Free text that describes something | Textarea with an AI generate button | Supplier description, dispute summary, hold reason. |
| Three or four fields that create one thing | Form dialog | Keeps the list in view behind it. |
| More fields than fit one screen | Sections in FieldSets, and an error summary on submit | Narrow forms cap at 48rem. |
Labels, help and required fields#
Name every control, explain the format before the mistake, and mark whichever is the exception, required or optional.
| Part | Rule | Example |
|---|---|---|
| Label | A noun in sentence case with no colon, always visible above the control. | Remit-to email |
| Optional marker | When most fields are required, mark the optional ones with (optional) in Slate Meta. When most are optional, mark the required ones Required. Never both, never asterisks alone. | Doing business as (optional) |
| Help text | One sentence of format or consequence, under the control, in 12px Slate Meta. Shown before the mistake, not instead of the error. | From box 3 of their W-9. |
| Placeholder | A realistic example value. Never the instruction and never the only name. | 12-3456789 |
| Error | Starts with a verb and gives the fix, under the field that failed. Never blames, never just Invalid. | Enter a 9-digit EIN, like 12-3456789. |
Validation timing#
Check on blur and on submit. While someone is typing, the only change an error makes is to disappear.
Keystroke against blur
Try both. Keystroke validation shouts before you've finished; blur validation waits, then gets out of the way as soon as you fix it.
Type an address. The error appears on the first letter, before you've had a chance to finish.
import { Field, FieldError, FieldLabel } from "@oration/canon/components/field";import { Input } from "@oration/canon/components/input";import { SegmentedControl } from "@oration/canon/components/segmented-control";import { text } from "@oration/canon/lib/form-controls";import * as React from "react";export function ValidationTiming() { type Timing = "keystroke" | "blur"; const id = React.useId(); const [timing, setTiming] = React.useState<Timing>("keystroke"); const [value, setValue] = React.useState(""); const [error, setError] = React.useState<string | undefined>(); const invalid = (text: string) => !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(text.trim()); const message = "Enter an email address, like ap@halcyonpackaging.com."; return ( <div className="flex w-full max-w-sm flex-col gap-4 text-left"> <SegmentedControl<Timing> label="Validate" value={timing} onValueChange={(next) => { setTiming(next); setValue(""); setError(undefined); }} options={[ { value: "keystroke", label: "On every keystroke" }, { value: "blur", label: "On blur, Canon" }, ]} /> <Field> <FieldLabel htmlFor={id}>Remit-to email</FieldLabel> <Input id={id} type="email" placeholder="ap@halcyonpackaging.com" value={value} onChange={(event) => { const next = event.target.value; setValue(next); if (timing === "keystroke") setError(invalid(next) ? message : undefined); else if (error && !invalid(next)) setError(undefined); }} onBlur={() => { if (timing === "blur" && value.trim()) setError(invalid(value) ? message : undefined); }} aria-invalid={error ? true : undefined} aria-describedby={error ? `${id}-error` : undefined} /> <FieldError id={`${id}-error`} className="text-xs"> {error} </FieldError> </Field> <p className="text-xs text-muted-foreground"> {timing === "keystroke" ? "Type an address. The error appears on the first letter, before you've had a chance to finish." : "Type an address and press Tab. The error waits for you to leave the field, and clears as soon as the fix is typed."} </p> </div> );}| Moment | What happens |
|---|---|
| While typing | Nothing new appears. An error already showing re-checks and clears the moment the value is valid. |
| On blur | Check the format of a field that has a value. Leave an empty required field alone; someone tabbing through hasn't failed yet. |
| On submit | Check everything. Mark each failing control aria-invalid, show its error under it, and for long forms show an error summary at the top and move focus to it. Short forms move focus to the first invalid control. |
| After the server answers | A server error about one field (This EIN already belongs to Halcyon Packaging.) lands under that field like any other. A failure that isn't about a field is an error toast. See Feedback and undo. |
Long forms and the submit button#
When a form runs past one screen, errors below the fold are invisible. An error summary lists them and links to each.
- Show the summary at the top of the form after a failed submit, titled with the count: Fix 3 fields to add this supplier.
- Each entry names the field and repeats its error, and moves focus to that field when chosen.
- Move focus to the summary so keyboard and screen reader users start there. Remove each entry as its field is fixed.
- Keep the inline error under each field too. The summary is a map; the inline error is the instruction.
- Forms short enough to see whole (a dialog of three fields) skip the summary and focus the first invalid control.
Enter the supplier's legal name as it appears on their W-9.
Typed forms in code#
A Zod schema is the single source of a form's types and messages. A small adapter connects each field to its control, so binding a field to the wrong control doesn't compile.
The form's values are the schema's input type, so default values have to match it exactly, and submit receives the parsed output. Every check carries its message verbatim; Zod's default messages never reach the screen. The supplier form above is built this way.
| Form | Use | Notes |
|---|---|---|
| Dialogs, sheets, sign in and create forms that submit once | useZodForm from @oration/canon/hooks/use-zod-form, with form.Field and the adapters | Pass form.submit as the form's onSubmit. Reset dialogs with form.reset(defaults) when they open. |
| Settings and agent config pages with a SaveBar | useSettingsForm or useConfigForm with a schema | Sections keep their values and set props. save() validates first and keeps the form dirty when it fails; form.error(path) reads the message. |
| Live editors, such as procedure inspectors | Neither | They apply each change as it happens and have nothing to submit. |
| Field value | Adapter | Controls |
|---|---|---|
string | text, otp | Input, Textarea, PasswordInput, InputGroupInput; InputOTP. A string | null field passes { empty: null }. |
A string-literal union or a string id, or either | null | select, radio, choice, segmented | Select (with triggerProps on SelectTrigger), SelectField, TimezoneSelect; RadioGroup; ChoiceCards; SegmentedControl, or ToggleGroup through groupProps. |
number | null | numberText | A text input with parse and format; defaults to whole numbers grouped like 48,000. Empty writes null. |
number | slider, duration | SliderField; DurationPicker in milliseconds. |
boolean | toggleSwitch, checkbox | Switch; Checkbox. |
Date | calendar | Calendar, single date. |
string[], or a list of a union's members | tags, checkboxGroup | TagInput or ChipInput; one Checkbox per member through item(value). |
File[] | dropzone | Dropzone. The form renders the file list itself. |
BusinessHours | businessHours | BusinessHoursPicker. |
jsonField(), jsonSchemaField() | json, jsonSchema | JsonViewer with editable; JsonSchemaBuilder. Both are leaves in form state, because their types are recursive. |
- Adapters return the value, the change handler,
onBlurandaria-invalid(trueor absent) to spread on the control, plusinvalidanderrorfor you to place. - They never set
id,className,placeholder,aria-describedbyorautoFocus. Keep youruseIdids and composearia-describedbyfrom the help and error ids. - Only the first message per field is exposed, so
FieldErrorshows one line, not a list. - Submit is never disabled for invalid input. Disable it only while the request is in flight.
- Focus after a failed submit is yours:
onInvalidgets every issue in schema order. Short forms focus the first invalid control; long forms focus the error summary. - A new control gets one new adapter beside the others. A field typed
any, or a value type no adapter takes, can't be bound at all.
| visibility | Behavior | Used by |
|---|---|---|
{ reveal: "submit", clear: "edit" }, the default | Errors appear on submit. Editing a field hides its error until the next submit. | Create deal, most dialogs |
{ reveal: "submit", clear: "fixed" } | Errors appear on submit and stay until the value passes. | Workspace name |
{ reveal: "blur-if-filled", clear: "fixed" } | Leaving a field with a value reveals its error; submit reveals all of them. A revealed error clears once fixed, and no new one appears mid-keystroke. | Long forms like the supplier form |
Field layout#
Label above for forms and dialogs, label left for settings. One column unless two short fields belong together.
Field, label above. Forms, dialogs, sign in.
Invoices above this amount, in USD, need a second approver.
SettingsRow, label left. Settings pages.
import { Field, FieldDescription, FieldLabel } from "@oration/canon/components/field";import { Input } from "@oration/canon/components/input";import { descriptionId, SettingsGroup, SettingsRow } from "@oration/canon/components/settings-section";import { text } from "@oration/canon/lib/form-controls";import * as React from "react";export function FieldLayouts() { const id = React.useId(); const [stacked, setStacked] = React.useState("25000"); const [row, setRow] = React.useState("25000"); return ( <div className="grid w-full gap-6 text-left lg:grid-cols-2"> <div className="flex min-w-0 flex-col gap-2"> <p className="text-xs text-muted-foreground"> Field, label above. Forms, dialogs, sign in. </p> <div className="rounded-xl bg-card p-5 shadow-border"> <Field> <FieldLabel htmlFor={`${id}-stacked`}> Approval threshold </FieldLabel> <Input id={`${id}-stacked`} inputMode="decimal" value={stacked} onChange={(event) => setStacked(event.target.value)} aria-describedby={`${id}-stacked-help`} className="tabular-nums" /> <FieldDescription id={`${id}-stacked-help`} className="text-xs" > Invoices above this amount, in USD, need a second approver. </FieldDescription> </Field> </div> </div> <div className="flex min-w-0 flex-col gap-2"> <p className="text-xs text-muted-foreground"> SettingsRow, label left. Settings pages. </p> <SettingsGroup> <SettingsRow label="Approval threshold" htmlFor={`${id}-row`} description="Invoices above this amount, in USD, need a second approver." > <Input id={`${id}-row`} inputMode="decimal" value={row} onChange={(event) => setRow(event.target.value)} aria-describedby={descriptionId(`${id}-row`)} className="tabular-nums sm:w-32" /> </SettingsRow> </SettingsGroup> </div> </div> );}| Measure | Value | Notes |
|---|---|---|
| Between fields | 20px | FieldGroup default. Dialogs use 16px (gap-4). |
| Inside a field | 8px | Label, control, help and error. |
| Controls | 32px | 10px corners, 1px Field Stroke, 10px side padding. |
| Input text below 768px | 16px | Inputs render text-base on small screens so mobile browsers don't zoom, then 14px from 768px. |
| Narrow form column | 48rem | Settings and long forms cap here. |
| Settings row padding | 16px | Label left, control right, stacked below 640px. |
Help and error text size
DESIGN.md sets helper text as 12px Caption, but FieldDescription and FieldError default to 14px. The examples here pass className="text-xs". Don't pass text-13; the cn inside packages/canon reads it as a color.
AI on descriptive fields#
Any free-text field that describes something gets an AI generate button beside its label. It drafts, toasts Draft added with Undo, and leaves the text for the person to edit.
Drafted from the call transcript and both invoices. Edit before you save.
import { AIGenerateButton } from "@oration/canon/components/ai/ai-button";import { Label } from "@oration/canon/components/label";import { Textarea } from "@oration/canon/components/textarea";import { text } from "@oration/canon/lib/form-controls";import * as React from "react";export function DescriptiveField() { const id = React.useId(); const [summary, setSummary] = React.useState(""); const previous = React.useRef(""); return ( <div className="flex w-full max-w-md flex-col gap-2 text-left"> <div className="flex items-center justify-between gap-3"> <Label htmlFor={id}>Dispute summary</Label> <AIGenerateButton label="Draft" onGenerate={() => "Northwind Freight billed INV-20388 twice: once on Sep 14 and again on Sep 21 for the same Reno to Sacramento shipment (BOL 88213). We paid the first. The supplier agreed on the Sep 25 call to void the second." } onResult={(text) => { previous.current = summary; setSummary(text); }} onUndo={() => setSummary(previous.current)} /> </div> <Textarea id={id} value={summary} onChange={(event) => setSummary(event.target.value)} placeholder="What's disputed, what was agreed and what happens next" aria-describedby={`${id}-help`} className="min-h-24" /> <p id={`${id}-help`} className="text-xs text-muted-foreground"> Drafted from the call transcript and both invoices. Edit before you save. </p> </div> );}- Descriptive fields: supplier descriptions, dispute summaries, hold reasons, notes to suppliers, ticket summaries, procedure steps, campaign messages.
- Not for values: names, amounts, EINs, routing numbers and emails are typed or picked, never generated.
- The button is ghost and extra small, right-aligned on the label's row, labelled with a verb such as Draft or Rewrite.
- Long prompts use the full Prompt editor instead. See AI assistance.
Accessibility#
Field lays out the parts but doesn't wire them. The wiring is yours.
- Link each label with
htmlForand the control'sid, generated withReact.useId(). - Point
aria-describedbyat the help text and, while it shows, the error. Setaria-invalidon the failing control; that draws the red border and ring. - Group radios and checkboxes in
FieldSetwith aFieldLegend, so the group name is read with each option. - On a failed submit, move focus to the error summary or the first invalid control. Don't leave focus on the button with nothing announced.
- Keep submit enabled so it stays focusable and its result is announced.
- Inputs are 16px below 768px, so focusing one never zooms the page.
- Use
autoCompleteandinputMode(organization,email,numeric) so phones offer the right keyboard and autofill.
| Keys | Action |
|---|---|
| Tab | Moves to the next control. Help and error text aren't stops. |
| ↑↓ | Moves between radios in a group. |
| Enter | Submits from any text input. |
FieldError always renders role="alert", so several errors at once are announced together. Linking errors through aria-describedby is enough, and the gap is recorded on Field.
Components involved#
The parts this pattern is built from.
| Component | Role here |
|---|---|
| Field | Label, control, help and error in one group; FieldSet and FieldLegend for radios. |
| Input | Text, email and number entry, 32px, 16px text below 768px. |
| Textarea | Descriptive text that grows with content. |
| Radio group | Two to four exclusive options. |
| Select field | Five or more options. |
| Settings section | SettingsRow for label-left settings. |
| Alert | The error summary at the top of a long form. |
| AI generate button | Drafts descriptive fields with Undo. |
| Form dialog | Short forms that create one thing. |