Skip to content

Field

The form row that ties a label, control, description and error together.

Status
Stable
Category
Inputs
Adoption
Not used yet
import { Field } from "@oration/canon/components/field";
packages/canon/src/components/field.tsx

Add supplier

Cedarline pays suppliers from the details below.

Remittance advice goes here after every payment run.

Invoices still post; only payment waits.

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 FieldSet and FieldLegend to name a group of radios, checkboxes or choice cards, such as Payment method.
  • With FieldError under the control for the validation message, rendered always so it appears in place.
  • With FieldSeparator to 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

Every control has a label, visible or screen-reader only. A placeholder shows the expected format and is never the only name, because it disappears as soon as someone types.

Errors say how to fix it

An error is an instruction next to the field that failed: Enter a 9-digit EIN, like 12-3456789. It never blames and never says only Invalid.

Anatomy#

Remittance advice goes here.

  1. Field. A role="group" column with 8px gaps. data-invalid turns its text Signal Red; data-disabled dims the label.
  2. Label. FieldLabel, a Label at 14px weight 500, linked to the control with htmlFor.
  3. Control. Any input: Input, Textarea, Select, InputGroup, TagInput. It draws its own focus and invalid rings.
  4. Description. FieldDescription, 14px Slate Meta. Format hints and consequences, shown before the mistake.
  5. Error. FieldError, 14px Signal Red with role="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.

Payment method

How Cedarline pays Halcyon Logistics.

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.

or
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#

Rest

As printed on the invoice.

Focus

As printed on the invoice.

Invalid
Disabled

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>    );}
States
StateTreatment
RestLabel in Graphite Ink, description in Slate Meta, the control at rest.
Focus visibleDrawn by the control: an indigo border and a 3px ring at 50%. Field itself draws nothing.
Invaliddata-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.
Disableddata-disabled on Field dims the label and title to 50%; the control's own disabled dims it and blocks input.
Choice card hoverWhen a FieldLabel wraps a Field, it becomes a bordered card that fills Well Gray at 50% on hover.
Choice card checkedThe 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#

  • orientation is vertical (label above control, the default), horizontal (control beside its label, for switches and checkboxes) or responsive.
  • responsive stacks until the surrounding FieldGroup is at least 28rem wide, then lays out like horizontal. It reads a container query, so it only works inside a FieldGroup.
  • In a horizontal field, wrap the label and description in FieldContent so they stack beside the control; the row then aligns to the top.
  • FieldGroup stacks fields 20px apart and nested groups 16px apart. Most dialogs pass className="gap-4".
  • FieldSet renders a native <fieldset>, so disabled on it disables every control inside. It tightens to 12px when it holds a checkbox or radio group.
  • FieldLegend has two sizes: variant="legend" (16px, the default) for form sections and variant="label" (14px) when the group reads like one field.
  • FieldError takes children or an errors array (for example from a form library). Duplicate messages are dropped; one message renders as text and several as a bulleted list.
  • FieldDescription tightens 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#

Do. Keep a visible label above the control and put the format in the description or the placeholder.
Don't. Use the placeholder as the only label. It disappears as soon as someone types, and the field is left with no name on screen.
Do. Say exactly how to fix it, under the field that failed, and mark the control aria-invalid.

Invalid input

Don't. Show a generic message such as Invalid input, or collect errors in a banner away from the fields.

Sent after every payment run.

Do. Put switches and checkboxes in a horizontal field, with the label and its consequence beside the control.

Sent after every payment run.

Don't. Stack a switch under its label like a text input. The row reads as two things and the label loses its hit area.

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-3456789 or Northwind Freight LLC, never instructions.
  • Legends name the choice, not the action: Payment method, not Choose how to pay.

Accessibility#

  • Link each label with htmlFor and the control's id. Generate ids with React.useId() in reusable forms.
  • Mark the failing control aria-invalid="true" and point aria-describedby at the description and error ids. data-invalid on 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 FieldSet and FieldLegend for 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.
Keyboard interactions
KeysAction
TabMoves to the next control. Labels and descriptions are not stops.
SpaceToggles a checkbox or switch in a horizontal field, or selects a choice card.
↑↓Moves between radios inside a FieldSet.

Design tokens#

Design tokens
TokenUsed for
--foregroundLabel and title text
--muted-foregroundDescription and separator text
--destructiveError text and the invalid label
--borderThe separator hairline
--primaryChecked choice card: 5% fill and 30% border (10% and 20% in dark)
--mutedChoice card hover fill at 50%
--ringChoice card focus border and 3px ring at 50%
--radius-lg10px corners on the choice card

API reference#

Field

One labelled control. Renders role="group".

Other props spread onto <div>.

Props of Field
PropTypeDefaultDescription
orientation"vertical" | "horizontal" | "responsive""vertical"Label above the control, beside it, or stacked until the parent FieldGroup is 28rem wide.
data-invalidbooleanNo defaultTurns the field's text Signal Red. Styling only.
data-disabledbooleanNo defaultDims 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>).

Props of FieldLabel
PropTypeDefaultDescription
htmlForstringNo defaultThe id of the control it names.

FieldDescription

Helper text in Slate Meta. Links inside it underline.

Other props spread onto <p>.

Props of FieldDescription
PropTypeDefaultDescription
idstringNo defaultPoint 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>.

Props of FieldError
PropTypeDefaultDescription
errorsArray<{ message?: string } | undefined>No defaultMessages to show when there are no children. Duplicates are dropped; several render as a list.
childrenReactNodeNo defaultThe 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>.

Props of FieldSet
PropTypeDefaultDescription
disabledbooleanNo defaultDisables every control inside.

FieldLegend

The group's name. Must be the first child of FieldSet.

Other props spread onto <legend>.

Props of FieldLegend
PropTypeDefaultDescription
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>.

Props of FieldSeparator
PropTypeDefaultDescription
childrenReactNodeNo defaultText 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.