Skip to content

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

A visible label above the control, or a screen-reader label when the context names it. A placeholder is an example value, never the name.

Validate late, clear early

Errors appear on blur or submit, never mid-keystroke. Once shown, an error re-checks as you type and clears the moment the fix is in.

Submit stays enabled

Never disable submit to signal bad input. Pressing it is how people find out what is missing, and how screen reader users hear why.

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.

New supplier

Everything except the optional fields is needed before Cedarline can pay this supplier.

From box 3 of their W-9.

Remittance advice goes here after each payment run.

Payment method

Nora reads this when a supplier calls about a payment.

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.

Choosing form parts
You needUseNotes
A labelled input in a form or dialogField with FieldLabel, the control, FieldDescription and FieldErrorLabel above. The default.
A setting on a settings pageSettingsRow in a SettingsGroup (Settings section)Label and description left, control right. Stacks below 640px.
A switch or checkbox with a consequenceA horizontal Field, or SettingsRow inlineThe control sits beside its label at every width.
Two to four options to compareRadio group in a FieldSet with a FieldLegendChoice card when each option needs a description.
Five or more optionsSelect field, or Combobox past about 15Payment terms, currency, timezone.
Free text that describes somethingTextarea with an AI generate buttonSupplier description, dispute summary, hold reason.
Three or four fields that create one thingForm dialogKeeps the list in view behind it.
More fields than fit one screenSections in FieldSets, and an error summary on submitNarrow 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.

The words in a field
PartRuleExample
LabelA noun in sentence case with no colon, always visible above the control.Remit-to email
Optional markerWhen 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 textOne 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.
PlaceholderA realistic example value. Never the instruction and never the only name.12-3456789
ErrorStarts 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.
Tax ID (EIN)
Do. Keep the label visible and put an example in the placeholder.
Don't. Use the placeholder as the label. It disappears on the first keystroke and leaves the field unnamed.
Legal nameRemit-to emailDoing business as (optional)
Do. Mark the exception. Here most fields are required, so only the optional one is marked.
Legal name *Remit-to email *Doing business as
Don't. Star every field. When everything carries a mark, the mark means nothing.

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>    );}
When validation runs
MomentWhat happens
While typingNothing new appears. An error already showing re-checks and clears the moment the value is valid.
On blurCheck the format of a field that has a value. Leave an empty required field alone; someone tabbing through hasn't failed yet.
On submitCheck 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 answersA 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.

Do. Leave Add supplier enabled. Pressing it reveals what's missing.
Don't. Disable submit until the form is valid. Nothing says why, and a disabled button can't be focused or explained.

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.

Which hook
FormUseNotes
Dialogs, sheets, sign in and create forms that submit onceuseZodForm from @oration/canon/hooks/use-zod-form, with form.Field and the adaptersPass form.submit as the form's onSubmit. Reset dialogs with form.reset(defaults) when they open.
Settings and agent config pages with a SaveBaruseSettingsForm or useConfigForm with a schemaSections 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 inspectorsNeitherThey apply each change as it happens and have nothing to submit.
Adapters in @oration/canon/lib/form-controls
Field valueAdapterControls
stringtext, otpInput, Textarea, PasswordInput, InputGroupInput; InputOTP. A string | null field passes { empty: null }.
A string-literal union or a string id, or either | nullselect, radio, choice, segmentedSelect (with triggerProps on SelectTrigger), SelectField, TimezoneSelect; RadioGroup; ChoiceCards; SegmentedControl, or ToggleGroup through groupProps.
number | nullnumberTextA text input with parse and format; defaults to whole numbers grouped like 48,000. Empty writes null.
numberslider, durationSliderField; DurationPicker in milliseconds.
booleantoggleSwitch, checkboxSwitch; Checkbox.
DatecalendarCalendar, single date.
string[], or a list of a union's memberstags, checkboxGroupTagInput or ChipInput; one Checkbox per member through item(value).
File[]dropzoneDropzone. The form renders the file list itself.
BusinessHoursbusinessHoursBusinessHoursPicker.
jsonField(), jsonSchemaField()json, jsonSchemaJsonViewer with editable; JsonSchemaBuilder. Both are leaves in form state, because their types are recursive.
  • Adapters return the value, the change handler, onBlur and aria-invalid (true or absent) to spread on the control, plus invalid and error for you to place.
  • They never set id, className, placeholder, aria-describedby or autoFocus. Keep your useId ids and compose aria-describedby from the help and error ids.
  • Only the first message per field is exposed, so FieldError shows 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: onInvalid gets 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.
When errors show
visibilityBehaviorUsed by
{ reveal: "submit", clear: "edit" }, the defaultErrors 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.

Invoices above this amount, in USD, need a second approver.
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>    );}
Layout measures
MeasureValueNotes
Between fields20pxFieldGroup default. Dialogs use 16px (gap-4).
Inside a field8pxLabel, control, help and error.
Controls32px10px corners, 1px Field Stroke, 10px side padding.
Input text below 768px16pxInputs render text-base on small screens so mobile browsers don't zoom, then 14px from 768px.
Narrow form column48remSettings and long forms cap here.
Settings row padding16pxLabel 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 htmlFor and the control's id, generated with React.useId().
  • Point aria-describedby at the help text and, while it shows, the error. Set aria-invalid on the failing control; that draws the red border and ring.
  • Group radios and checkboxes in FieldSet with a FieldLegend, 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 autoComplete and inputMode (organization, email, numeric) so phones offer the right keyboard and autofill.
Keyboard interactions
KeysAction
TabMoves to the next control. Help and error text aren't stops.
↑↓Moves between radios in a group.
EnterSubmits 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.

Components involved
ComponentRole here
FieldLabel, control, help and error in one group; FieldSet and FieldLegend for radios.
InputText, email and number entry, 32px, 16px text below 768px.
TextareaDescriptive text that grows with content.
Radio groupTwo to four exclusive options.
Select fieldFive or more options.
Settings sectionSettingsRow for label-left settings.
AlertThe error summary at the top of a long form.
AI generate buttonDrafts descriptive fields with Undo.
Form dialogShort forms that create one thing.