Field
The form row that ties a label, control, description and error together.
import { Button } from "@oration/canon/components/button";import { Field, FieldContent, FieldDescription, FieldError, FieldGroup, FieldLabel,} from "@oration/canon/components/field";import { Input } from "@oration/canon/components/input";import { Switch } from "@oration/canon/components/switch";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Hero() { const id = React.useId(); const ids = { name: `${id}-name`, email: `${id}-email`, tin: `${id}-tin`, w9: `${id}-w9`, }; const [name, setName] = React.useState("Northwind Freight LLC"); const [email, setEmail] = React.useState(""); const [tin, setTin] = React.useState("12-34567"); const [holdForW9, setHoldForW9] = React.useState(true); const [errors, setErrors] = React.useState<{ name?: string; email?: string; tin?: string; }>({}); const submit = (event: React.FormEvent<HTMLFormElement>) => { event.preventDefault(); const next = { name: name.trim() ? undefined : "Enter the supplier's legal name.", email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ? undefined : "Enter an email address, like ap@northwindfreight.com.", tin: /^\d{2}-\d{7}$/.test(tin) ? undefined : "Enter a 9-digit EIN, like 12-3456789.", }; setErrors(next); const first = (Object.keys(next) as (keyof typeof next)[]).find( (key) => next[key], ); if (first) { document.getElementById(ids[first])?.focus(); return; } toast.add({ type: "success", title: `Added ${name.trim()}`, description: holdForW9 ? "Payments are held until a W-9 is on file." : "Ready for the next payment run.", }); }; return ( <form noValidate onSubmit={submit} className="flex w-full max-w-md flex-col overflow-hidden rounded-xl bg-popover text-left shadow-lg" > <div className="flex flex-col gap-1 p-4 pb-0"> <p className="text-base leading-none font-medium text-foreground"> Add supplier </p> <p className="text-sm text-muted-foreground"> Cedarline pays suppliers from the details below. </p> </div> <FieldGroup className="gap-4 p-4"> <Field data-invalid={errors.name ? true : undefined}> <FieldLabel htmlFor={ids.name}>Legal name</FieldLabel> <Input id={ids.name} value={name} onChange={(event) => setName(event.target.value)} autoComplete="organization" aria-invalid={errors.name ? true : undefined} aria-describedby={ errors.name ? `${ids.name}-error` : undefined } /> <FieldError id={`${ids.name}-error`}> {errors.name} </FieldError> </Field> <Field data-invalid={errors.email ? true : undefined}> <FieldLabel htmlFor={ids.email}>Remit-to email</FieldLabel> <Input id={ids.email} type="email" inputMode="email" autoComplete="email" placeholder="ap@northwindfreight.com" value={email} onChange={(event) => setEmail(event.target.value)} aria-invalid={errors.email ? true : undefined} aria-describedby={[ `${ids.email}-description`, errors.email ? `${ids.email}-error` : null, ] .filter(Boolean) .join(" ")} /> <FieldDescription id={`${ids.email}-description`}> Remittance advice goes here after every payment run. </FieldDescription> <FieldError id={`${ids.email}-error`}> {errors.email} </FieldError> </Field> <Field data-invalid={errors.tin ? true : undefined}> <FieldLabel htmlFor={ids.tin}>Tax ID (EIN)</FieldLabel> <Input id={ids.tin} inputMode="numeric" autoComplete="off" placeholder="12-3456789" value={tin} onChange={(event) => setTin(event.target.value)} className="font-mono" aria-invalid={errors.tin ? true : undefined} aria-describedby={ errors.tin ? `${ids.tin}-error` : undefined } /> <FieldError id={`${ids.tin}-error`}> {errors.tin} </FieldError> </Field> <Field orientation="horizontal"> <FieldContent> <FieldLabel htmlFor={ids.w9}> Hold payments until a W-9 is on file </FieldLabel> <FieldDescription> Invoices still post; only payment waits. </FieldDescription> </FieldContent> <Switch id={ids.w9} checked={holdForW9} onCheckedChange={setHoldForW9} /> </Field> </FieldGroup> <div className="flex items-center justify-end gap-2 border-t border-border bg-muted/50 px-4 py-3"> <Button type="button" variant="ghost" onClick={() => { setErrors({}); toast.add({ title: "Discarded the new supplier" }); }} > Cancel </Button> <Button type="submit">Add supplier</Button> </div> </form> );}Usage#
Field is the form row: it ties a label, a control, a description and an error into one role="group" so every form in Oration stacks the same way and the whole row reads as invalid together. It is the most imported form part in the suite, in dialogs, sign-in and settings. Field lays things out; it does not wire them. You still set htmlFor on the label, and aria-invalid and aria-describedby on the control, pointing at the ids of the description and the error.
When to use
- For every labelled control in a form or dialog: Legal name, Remit-to email, Tax ID.
- With
orientation="horizontal"for switches and checkboxes that sit beside their label and description. - With
FieldSetandFieldLegendto name a group of radios, checkboxes or choice cards, such as Payment method. - With
FieldErrorunder the control for the validation message, rendered always so it appears in place. - With
FieldSeparatorto split two ways into the same form, such as single sign-on and email.
When not to use
- For a settings page row with the label on the left and the control on the right. Use Settings section
- For a labelled slider with a readout and a reset button. It brings its own label. Use Slider field
- For a standalone control with no description or error, such as a toolbar search. Use Search field
- For a few large radio options with descriptions, where the ready-made cards carry the selected ring. Use Choice card
- For a dialog that exists only to collect a few fields and submit. Use Form dialog
Every field has a label
Errors say how to fix it
Anatomy#
Remittance advice goes here.
- Field. A
role="group"column with 8px gaps.data-invalidturns its text Signal Red;data-disableddims the label. - Label.
FieldLabel, aLabelat 14px weight 500, linked to the control withhtmlFor. - Control. Any input:
Input,Textarea,Select,InputGroup,TagInput. It draws its own focus and invalid rings. - Description.
FieldDescription, 14px Slate Meta. Format hints and consequences, shown before the mistake. - Error.
FieldError, 14px Signal Red withrole="alert". Renders nothing while it has no content.
Examples#
Label, control and description
The default vertical field: label above, control, then a one-sentence hint. Mark optional fields rather than required ones.
Due dates are counted from the invoice date, not the receipt date.
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@oration/canon/components/field";import { Input } from "@oration/canon/components/input";import * as React from "react";export function Vertical() { const id = React.useId(); const [terms, setTerms] = React.useState("Net 30"); return ( <FieldGroup className="max-w-sm gap-4"> <Field> <FieldLabel htmlFor={`${id}-terms`}>Payment terms</FieldLabel> <Input id={`${id}-terms`} value={terms} onChange={(event) => setTerms(event.target.value)} aria-describedby={`${id}-terms-description`} /> <FieldDescription id={`${id}-terms-description`}> Due dates are counted from the invoice date, not the receipt date. </FieldDescription> </Field> <Field> <FieldLabel htmlFor={`${id}-po`}> PO number{" "} <span className="font-normal text-muted-foreground"> (optional) </span> </FieldLabel> <Input id={`${id}-po`} placeholder="PO-20931" className="font-mono" /> </Field> </FieldGroup> );}Validation
Validate on submit, mark the control aria-invalid, link the error with aria-describedby and move focus to it. Editing clears the error.
import { Button } from "@oration/canon/components/button";import { Field, FieldError, FieldLabel } from "@oration/canon/components/field";import { Input } from "@oration/canon/components/input";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Validation() { const id = React.useId(); const emailId = `${id}-email`; const [email, setEmail] = React.useState("maya.okafor@"); const [error, setError] = React.useState<string>(); return ( <form noValidate className="flex w-full max-w-sm flex-col gap-4" onSubmit={(event) => { event.preventDefault(); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { setError( "Enter a full email address, like maya.okafor@cedarline.io.", ); document.getElementById(emailId)?.focus(); return; } setError(undefined); toast.add({ type: "success", title: `Invite sent to ${email}`, }); }} > <Field data-invalid={error ? true : undefined}> <FieldLabel htmlFor={emailId}>Work email</FieldLabel> <Input id={emailId} type="email" value={email} onChange={(event) => { setEmail(event.target.value); if (error) setError(undefined); }} aria-invalid={error ? true : undefined} aria-describedby={error ? `${emailId}-error` : undefined} /> <FieldError id={`${emailId}-error`}>{error}</FieldError> </Field> <Button type="submit" className="self-start"> Send invite </Button> </form> );}Several errors
Pass an errors array, for example from a form library. Duplicates are dropped and more than one message renders as a list.
import { Field, FieldError, FieldLabel } from "@oration/canon/components/field";import { Input } from "@oration/canon/components/input";import * as React from "react";export function ErrorList() { const id = React.useId(); const routingId = `${id}-routing`; const [routing, setRouting] = React.useState("02100002"); const [touched, setTouched] = React.useState(true); const digits = routing.replace(/\D/g, ""); const errors = touched ? [ digits.length !== 9 ? { message: "Enter all 9 digits." } : undefined, /\D/.test(routing) ? { message: "Use digits only, without spaces or dashes." } : undefined, digits.length !== 9 ? { message: "Enter all 9 digits." } : undefined, ].filter(Boolean) : []; const invalid = errors.length > 0; return ( <Field data-invalid={invalid ? true : undefined} className="max-w-sm"> <FieldLabel htmlFor={routingId}>Routing number</FieldLabel> <Input id={routingId} inputMode="numeric" value={routing} onChange={(event) => setRouting(event.target.value)} onBlur={() => setTouched(true)} className="font-mono" aria-invalid={invalid ? true : undefined} aria-describedby={invalid ? `${routingId}-error` : undefined} /> <FieldError id={`${routingId}-error`} errors={errors} /> </Field> );}Horizontal
Switches and checkboxes sit beside their label. FieldContent stacks the label and description; the switch trails and the checkbox leads.
Suppliers get a PDF with every invoice a payment covers.
One approver can release any payment run.
import { Checkbox } from "@oration/canon/components/checkbox";import { Field, FieldContent, FieldDescription, FieldGroup, FieldLabel,} from "@oration/canon/components/field";import { Switch } from "@oration/canon/components/switch";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Horizontal() { const id = React.useId(); const [advice, setAdvice] = React.useState(true); const [approval, setApproval] = React.useState(false); return ( <FieldGroup className="max-w-md gap-4"> <Field orientation="horizontal"> <FieldContent> <FieldLabel htmlFor={`${id}-advice`}> Email remittance advice </FieldLabel> <FieldDescription> Suppliers get a PDF with every invoice a payment covers. </FieldDescription> </FieldContent> <Switch id={`${id}-advice`} checked={advice} onCheckedChange={(checked) => { setAdvice(checked); toast.add({ title: checked ? "Remittance advice on" : "Remittance advice off", }); }} /> </Field> <Field orientation="horizontal"> <Checkbox id={`${id}-approval`} checked={approval} onCheckedChange={(checked) => setApproval(checked)} /> <FieldContent> <FieldLabel htmlFor={`${id}-approval`}> Require a second approver over $25,000 </FieldLabel> <FieldDescription> {approval ? "Priya Raman or Tomás Ferreira must approve large runs." : "One approver can release any payment run."} </FieldDescription> </FieldContent> </Field> </FieldGroup> );}Responsive
orientation="responsive" stacks until the FieldGroup is 28rem wide, then puts the label beside the control. Switch the width to see it fold.
From your ERP.
Invoices after this wait a week.
import { Field, FieldContent, FieldDescription, FieldGroup, FieldLabel,} from "@oration/canon/components/field";import { Input } from "@oration/canon/components/input";import { SegmentedControl } from "@oration/canon/components/segmented-control";import { cn } from "@oration/canon/lib/utils";import * as React from "react";export function Responsive() { const id = React.useId(); const [width, setWidth] = React.useState<"narrow" | "wide">("wide"); return ( <div className="flex w-full flex-col items-center gap-6"> <SegmentedControl label="Container width" value={width} onValueChange={setWidth} options={[ { value: "narrow", label: "Narrow" }, { value: "wide", label: "Wide" }, ]} /> <FieldGroup className={cn( "gap-4 transition-[max-width] duration-200 ease-out motion-reduce:transition-none", width === "narrow" ? "max-w-xs" : "max-w-xl", )} > <Field orientation="responsive"> <FieldContent> <FieldLabel htmlFor={`${id}-vendor`}> Vendor ID </FieldLabel> <FieldDescription>From your ERP.</FieldDescription> </FieldContent> <Input id={`${id}-vendor`} defaultValue="V-004417" className="font-mono sm:w-56" /> </Field> <Field orientation="responsive"> <FieldContent> <FieldLabel htmlFor={`${id}-cutoff`}> Run cutoff </FieldLabel> <FieldDescription> Invoices after this wait a week. </FieldDescription> </FieldContent> <Input id={`${id}-cutoff`} defaultValue="Thursday, 5:00 PM CT" className="sm:w-56" /> </Field> </FieldGroup> </div> );}Fieldset and legend
A FieldSet names a group of choices. Wrapping a horizontal Field in a FieldLabel turns each option into a card with a title, a description and the radio.
import { Field, FieldContent, FieldDescription, FieldLabel, FieldLegend, FieldSet, FieldTitle,} from "@oration/canon/components/field";import { RadioGroup, RadioGroupItem } from "@oration/canon/components/radio-group";import * as React from "react";export function FieldSetDemo() { const id = React.useId(); const [method, setMethod] = React.useState("ach"); const methods = [ { value: "ach", title: "ACH", description: "2 business days" }, { value: "check", title: "Check", description: "Mailed, 5 to 7 days" }, { value: "card", title: "Virtual card", description: "Same day, 2.5% fee", }, ]; return ( <FieldSet className="w-full max-w-lg"> <FieldLegend variant="label">Payment method</FieldLegend> <FieldDescription> How Cedarline pays Halcyon Logistics. </FieldDescription> <RadioGroup value={method} onValueChange={(value) => setMethod(String(value))} className="grid-cols-1 gap-2 sm:grid-cols-3" > {methods.map((option) => ( <FieldLabel key={option.value} htmlFor={`${id}-${option.value}`} > <Field orientation="horizontal" className="items-start"> <FieldContent> <FieldTitle className="text-[13px]"> {option.title} </FieldTitle> <FieldDescription className="text-xs leading-4"> {option.description} </FieldDescription> </FieldContent> <RadioGroupItem value={option.value} id={`${id}-${option.value}`} /> </Field> </FieldLabel> ))} </RadioGroup> </FieldSet> );}Separator
FieldSeparator splits two ways into the same form, such as single sign-on and email, with a word on the rule.
import { Button } from "@oration/canon/components/button";import { Field, FieldLabel, FieldSeparator } from "@oration/canon/components/field";import { Input } from "@oration/canon/components/input";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Separator() { const id = React.useId(); return ( <div className="flex w-full max-w-xs flex-col"> <Button type="button" variant="outline" onClick={() => toast.add({ title: "Redirecting to Okta", description: "cedarline.okta.com", }) } > Continue with Okta </Button> <FieldSeparator className="my-6">or</FieldSeparator> <form className="flex flex-col gap-4" onSubmit={(event) => { event.preventDefault(); toast.add({ title: "Check your email for a sign-in code" }); }} > <Field> <FieldLabel htmlFor={`${id}-email`}>Email</FieldLabel> <Input id={`${id}-email`} type="email" autoComplete="username" placeholder="name@company.com" /> </Field> <Button type="submit">Continue with email</Button> </form> </div> );}States#
As printed on the invoice.
As printed on the invoice.
As printed on the invoice.
import { Field, FieldDescription, FieldError, FieldLabel } from "@oration/canon/components/field";import { Input } from "@oration/canon/components/input";import { cn } from "@oration/canon/lib/utils";export function StatesRow() { const states = [ { name: "Rest", input: "", invalid: false, disabled: false }, { name: "Focus", input: "border-ring ring-3 ring-ring/50", invalid: false, disabled: false, }, { name: "Invalid", input: "", invalid: true, disabled: false }, { name: "Disabled", input: "", invalid: false, disabled: true }, ]; return ( <div className="grid w-full grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4"> {states.map((state) => ( <div key={state.name} className="flex min-w-0 flex-col gap-3"> <span className="text-xs text-muted-foreground"> {state.name} </span> <Field data-invalid={state.invalid || undefined} data-disabled={state.disabled || undefined} className="pointer-events-none" > <FieldLabel htmlFor={`states-${state.name}`}> Invoice number </FieldLabel> <Input id={`states-${state.name}`} tabIndex={-1} defaultValue={state.invalid ? "INV" : "INV-20417"} disabled={state.disabled} aria-invalid={state.invalid || undefined} className={cn("font-mono", state.input)} /> {state.invalid ? ( <FieldError> Use the full number, like INV-20417. </FieldError> ) : ( <FieldDescription> As printed on the invoice. </FieldDescription> )} </Field> </div> ))} </div> );}| State | Treatment |
|---|---|
| Rest | Label in Graphite Ink, description in Slate Meta, the control at rest. |
| Focus visible | Drawn by the control: an indigo border and a 3px ring at 50%. Field itself draws nothing. |
| Invalid | data-invalid on Field turns the label Signal Red. aria-invalid on the control gives it a red border and a 3px red ring at 20%. FieldError shows the message. |
| Disabled | data-disabled on Field dims the label and title to 50%; the control's own disabled dims it and blocks input. |
| Choice card hover | When a FieldLabel wraps a Field, it becomes a bordered card that fills Well Gray at 50% on hover. |
| Choice card checked | The card takes a 5% indigo fill and a 30% indigo border once the control inside is checked, and a 3px ring when it has keyboard focus. |
Behavior#
orientationisvertical(label above control, the default),horizontal(control beside its label, for switches and checkboxes) orresponsive.responsivestacks until the surroundingFieldGroupis at least 28rem wide, then lays out likehorizontal. It reads a container query, so it only works inside aFieldGroup.- In a horizontal field, wrap the label and description in
FieldContentso they stack beside the control; the row then aligns to the top. FieldGroupstacks fields 20px apart and nested groups 16px apart. Most dialogs passclassName="gap-4".FieldSetrenders a native<fieldset>, sodisabledon it disables every control inside. It tightens to 12px when it holds a checkbox or radio group.FieldLegendhas two sizes:variant="legend"(16px, the default) for form sections andvariant="label"(14px) when the group reads like one field.FieldErrortakeschildrenor anerrorsarray (for example from a form library). Duplicate messages are dropped; one message renders as text and several as a bulleted list.FieldDescriptiontightens its top margin when it sits right under a legend or before the last element, so hint and error don't drift apart.
Do and don't#
aria-invalid.Invalid input
Sent after every payment run.
Sent after every payment run.
Content#
- Labels are nouns in sentence case with no colon: Remit-to email, not Remit-To Email:.
- Mark the exception, not the rule. If most fields are required, mark the optional ones with (optional) in Slate Meta.
- Descriptions state the format or the consequence in one sentence: Payments to this supplier are held until a W-9 is on file.
- Errors start with a verb and give the fix: Enter an email address, like ap@northwindfreight.com.
- Placeholders are examples of real values, such as
12-3456789or Northwind Freight LLC, never instructions. - Legends name the choice, not the action: Payment method, not Choose how to pay.
Accessibility#
- Link each label with
htmlForand the control'sid. Generate ids withReact.useId()in reusable forms. - Mark the failing control
aria-invalid="true"and pointaria-describedbyat the description and error ids.data-invalidon Field is styling only. - FieldError carries
role="alert", so its message is announced as soon as it renders. Validate on submit or on blur, not on every keystroke. - On submit, move focus to the first invalid control.
- Keep the submit button enabled; show errors after a submit attempt so screen reader users learn why nothing happened.
- Use
FieldSetandFieldLegendfor radio and checkbox groups so the group name is announced with each option. - Inputs render at 16px below 768px so mobile browsers don't zoom on focus.
| Keys | Action |
|---|---|
| Tab | Moves to the next control. Labels and descriptions are not stops. |
| Space | Toggles a checkbox or switch in a horizontal field, or selects a choice card. |
| ↑↓ | Moves between radios inside a FieldSet. |
Design tokens#
| Token | Used for |
|---|---|
--foreground | Label and title text |
--muted-foreground | Description and separator text |
--destructive | Error text and the invalid label |
--border | The separator hairline |
--primary | Checked choice card: 5% fill and 30% border (10% and 20% in dark) |
--muted | Choice card hover fill at 50% |
--ring | Choice card focus border and 3px ring at 50% |
--radius-lg | 10px corners on the choice card |
API reference#
Field
One labelled control. Renders role="group".
Other props spread onto <div>.
| Prop | Type | Default | Description |
|---|---|---|---|
orientation | "vertical" | "horizontal" | "responsive" | "vertical" | Label above the control, beside it, or stacked until the parent FieldGroup is 28rem wide. |
data-invalid | boolean | No default | Turns the field's text Signal Red. Styling only. |
data-disabled | boolean | No default | Dims the label and title to 50%. |
FieldLabel
The control's visible name. Wrapping a Field in it turns it into a choice card.
Other props spread onto Label (<label>).
| Prop | Type | Default | Description |
|---|---|---|---|
htmlFor | string | No default | The id of the control it names. |
FieldDescription
Helper text in Slate Meta. Links inside it underline.
Other props spread onto <p>.
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | No default | Point the control's aria-describedby at it. |
FieldError
The validation message, in Signal Red with role="alert". Renders nothing when empty.
Other props spread onto <div>.
| Prop | Type | Default | Description |
|---|---|---|---|
errors | Array<{ message?: string } | undefined> | No default | Messages to show when there are no children. Duplicates are dropped; several render as a list. |
children | ReactNode | No default | The message. Wins over errors. |
FieldGroup
Stacks fields 20px apart and is the container that responsive fields measure.
Other props spread onto <div>.
No props of its own.
FieldSet
A native fieldset for a named group of controls.
Other props spread onto <fieldset>.
| Prop | Type | Default | Description |
|---|---|---|---|
disabled | boolean | No default | Disables every control inside. |
FieldLegend
The group's name. Must be the first child of FieldSet.
Other props spread onto <legend>.
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "legend" | "label" | "legend" | 16px for form sections, 14px when it reads like a field label. |
FieldContent
Stacks a label and description beside a control in a horizontal field.
Other props spread onto <div>.
No props of its own.
FieldTitle
Label-styled text that is not a <label>, for the title inside a choice card whose wrapper is already the label.
Other props spread onto <div>.
No props of its own.
FieldSeparator
A hairline between parts of a form, with optional centered text such as or.
Other props spread onto <div>.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | No default | Text centered on the rule. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
Field doesn't generate or connect ids. Every call site sets htmlFor, aria-invalid and aria-describedby by hand, and the sign-in forms carry their own describedBy helper to do it.
FieldDescription and FieldError are 14px, while DESIGN.md sets helper text as 12px Caption; product call sites override them to text-13 or text-xs.
className="text-13" on FieldDescription or FieldError doesn't do what it says. The cn inside packages/canon reads text-13 as a color, so it removes Slate Meta (or Signal Red) and leaves the 14px size: the nine product descriptions that pass it render at 14px in Graphite Ink. Use text-[13px], as the examples here do.
FieldGroup's default 20px gap sits between the named 16px and 24px steps; most product dialogs override it to gap-4.
data-invalid recolors the label red as well as the control. DESIGN.md only specifies the red border and ring on the control.
The choice-card recipe (a FieldLabel wrapping a Field) draws a CSS border and a 5% indigo fill when checked. DESIGN.md gives selected choice cards a 1.5 to 2px indigo ring; Choice card is the component that follows it.
FieldSeparator paints its text on bg-background, so on a card in dark theme, or in a well, the label sits on a visible patch.
FieldError always uses role="alert". The accessibility bar reserves alerts for urgent errors not tied to a control; field errors are already linked through aria-describedby.
27 product files hand-roll <fieldset> and a styled <legend> (for example the shift and public key dialogs in settings) instead of using FieldSet and FieldLegend, which only 5 files import.