Form
A complete form layout — header, body, sections, rows, footer, error summary and submit banner — built on Base UI's Form primitive with a size/rounded/layout cascade, validation modes, focus-managed server errors, and full RTL support.
Playground
Installation
pnpm add @tessinaui/uiUsage
import {
FormRoot,
FormHeader,
FormTitle,
FormDescription,
FormBody,
FormSection,
FormRow,
FormDivider,
FormFooter,
FormErrorSummary,
FormSubmitError,
FormHelperText,
} from "@tessinaui/ui";<FormRoot
variant="outline"
errors={serverErrors}
onFormSubmit={(values) => save(values)}
validationMode="onBlur"
>
<FormHeader>
<FormTitle>Edit profile</FormTitle>
<FormDescription>Update your public information.</FormDescription>
</FormHeader>
<FormErrorSummary />
<FormBody>
<FormRow>
<Field name="firstName" label="First name" required />
<Field name="lastName" label="Last name" required />
</FormRow>
<Field name="email" label="Email" type="email" required />
<Switch label="Email me product updates" defaultChecked />
</FormBody>
<FormFooter>
<Button variant="secondary">Cancel</Button>
<Button type="submit">Save changes</Button>
</FormFooter>
</FormRoot>Examples
Default
A profile form with a header, a two-up name row, an email field, and a footer.
Variants
Four visual treatments — ghost (structure only), outline, filled, and elevated.
Sizes
Five sizes from xs to xl scale the title, padding, gaps, and footer spacing together — and cascade into every control (see the cascade example).
Cascade
One size + rounded on the root and every control follows: the input family 1:1, Switch on its gentler scale, the Button one step down to share the field's centerline, component-scoped scales mapped (DatePicker, SegmentedControl, ColorPicker's trigger), and controls inside a Fieldset or a plain wrapping div included.
Horizontal layout
layout="horizontal" cascades labelPlacement="start" into every field-family child; any child can opt back out. It deliberately never touches Checkbox/Radio/Switch — their labelPlacement positions the label beside the control, a different meaning under the same prop name. Below sm the start column collapses automatically — labels stack above their controls, so phones never pay for a side column.
The label column has a fixed width (labelWidth, default md = 8rem), so every control in the form starts at the same x no matter how long each label is. Sizing the column to its own label — what a bare flex row does — staggers the control edges row by row, which is why every design system that ships horizontal forms specifies a track instead (SLDS 33%, PatternFly 150px, Fluent 33%, Ant labelCol). Labels wrap inside the track rather than truncating; truncation would keep the accessible name but fail WCAG 1.4.4/1.4.10 for zoom users.
Set labelWidth on FormRoot to retune the whole form, or on one child to override it. auto restores content-width sizing.
Inline layout
layout="inline" turns FormBody into one wrapping, bottom-aligned row — the newsletter pattern.
Row weights
FormRow weights gives a row unequal columns — the checkout idiom. Rows still stack to one column below sm.
Sections
Group related fields with FormSection, FormRow, and FormDivider inside a single form.
Required-mark policy
requiredMark renders the marking-convention legend in the header. Mark the minority: mostly-optional forms mark required fields (asterisk), mostly-required forms mark optional ones (optional). The per-field required / optional props remain the source of the actual markers.
Error summary
The WCAG 3.3.1 pattern (GOV.UK model): when the server errors map arrives, focus moves to the summary; each entry is an action that moves focus to its field.
Server errors
The errors map flows to every Field whose name matches — inline message, aria-invalid, cleared when the user edits. FormSubmitError carries the top-level failure no single field owns. focusOnError="first-field" skips the summary and focuses the first errored control.
Validation modes
validationMode decides when fields validate — on submit (default; validate late, re-validate early), on blur, or on change. actionsRef validates imperatively.
Async submit
isSubmitting marks the form busy — aria-busy, data-submitting, a polite announcement — while controls stay focusable. The submit button carries the visible pending state; statusMessage announces the result.
Submit error
FormSubmitError shows a tinted, bordered banner with role="alert" for top-level errors — at the top of the form or right above the footer.
Footer actions
Alignment doctrine plus the sticky mobile footer: end for dialogs and cards (default), start for full pages, between with a tertiary action; sticky pins the bar with safe-area padding. Below sm every footer stacks its actions full-width.
Intents
intent tints the card border and helper text — meaning, not weight. (intent="primary" is a deprecated alias and intentionally absent.)
Account flow
The forgot-password archetype: capped measure, back link, one field, full-width primary action, text-link footer.
On color
tone="on-color" derives the chrome and text from the surface ink a plate publishes.
Multi-step
One FormRoot per step; on a step change move focus to the new step's heading ("Step n of N" lives in the heading text).
Loading
FormSkeleton reserves the form's chrome, header, fields, and footer while data loads.
When to use
Form is the top-level container for any form-shaped interaction — sign-in, sign-up, profile editing, settings panes, checkout, multi-section onboarding. It bundles:
- Visual chrome (variants, padding, intent border accent, on-color tone)
- Layout primitives (
Header,Body,Section,Row,Footer) and a size/rounded/layout cascade into every control - Submission state (
isSubmittingsetsaria-busyand announces politely — without locking controls) - Server-side error reporting (
errorsmap keyed by fieldname,FormErrorSummarywith focus management,FormSubmitErrorfor top-level failures)
For a single grouped set of controls without form-level submission, use Fieldset. For an individual labeled input, use Field.
API Reference
FormRoot props
| Prop | Type | Default | Description |
|---|---|---|---|
size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | Scales title, padding, gaps, footer spacing — and cascades to every control |
variant | "ghost" | "outline" | "filled" | "elevated" | "ghost" | Visual treatment — ghost is just structure, the others wrap everything in a card |
rounded | "none" | "sm" | "md" | "lg" | "full" | "md" | Card corner radius (non-ghost variants). full renders the §2 panel cap (rounded-3xl) and cascades |
intent | "none" | "error" | "warning" | "success" | "info" | "none" | Border accent + helper text colour. "primary" is a deprecated alias (renders for one minor, warns in dev) |
tone | "default" | "on-color" | "default" | on-color derives chrome/text from the surface ink for coloured plates |
dir | "ltr" | "rtl" | "ltr" | Text direction |
layout | "vertical" | "horizontal" | "inline" | "vertical" | Field label layout; inline lays the body out as one wrapping row |
labelWidth | "sm" | "md" | "lg" | "xl" | "auto" | CSS length | "md" | Start-label column width at layout="horizontal" — sm 6rem · md 8rem · lg 11rem · xl 14rem · auto sizes to the label. Cascades into field children; a child's own value wins |
requiredMark | "asterisk" | "optional" | "none" | — | Marking-policy legend rendered at the end of FormHeader. Unset = no legend |
requiredLegend | ReactNode | null | derived | Override (or suppress) the legend line |
maxWidth | number | string | — | Measure cap applied as inline max-width |
validationMode | "onSubmit" | "onBlur" | "onChange" | "onSubmit" | When fields validate. Field.Root's own validationMode always wins |
errors | Record<string, string | string[]> | — | Server-side errors keyed by field name |
focusOnError | "summary" | "first-field" | "none" | "summary" | Where focus lands when errors arrives. summary falls back to the first errored control if no FormErrorSummary is rendered |
onFormSubmit | (values, eventDetails) => void | Promise<void> | — | Submit handler with parsed form values; preventDefault() is called on the native event |
actionsRef | RefObject<{ validate(name?) }> | — | Imperative validation |
isSubmitting | boolean | false | Sets aria-busy + data-submitting and announces submittingLabel. Controls stay operable — pair with <Button isLoading type="submit"> and ignore repeat submits |
submittingLabel | ReactNode | "Submitting" | Announced through the polite status region while busy |
statusMessage | ReactNode | — | Announce a result ("Changes saved") through the same region |
disabled | boolean | false | Disables every control via a native <fieldset disabled> |
The cascade
size, rounded and layout flow into children by displayName, recursing through the Form's own layout parts, FieldsetRoot/FieldsetBody, React fragments, and plain DOM wrappers. A child's own prop always wins.
- 1:1 size + rounded: Field, FieldDropdown, Search, Textarea, Combobox, Select, TimePicker, Tokenizer, OtpInput, FileUpload, Rating, Slider, NumberField, Stepper, ToggleButton/ToggleGroup, the Checkbox and Radio families, Fieldset
- Mapped scales: Button/IconButton/SplitButton (one step down), Switch (gentler track scale), DatePicker (
sm|md|lg), Picker (sm|md), PromptInput (sm|md|lg), SegmentedControl (caps atlg), ColorPicker (triggerSizeon the Button scale — its ownsizeis a panel width) labelWidth: cascades to the same set aslabelPlacement, and only when you set it onFormRoot.layout→labelPlacement: Field, FieldDropdown, Textarea, Select, Combobox, NumberField, DatePicker only. Checkbox/Radio/Switch (start|end= label side) and Slider (hidden|top) use the same prop name with a different meaning and are never touched.
FormHeader / Title / Description
FormHeader is a flex column wrapper that also renders the requiredMark legend. FormTitle renders an <h2> by default (level 1–4) and adopts an auto id that FormRoot points aria-labelledby at, making the form a named landmark; passing your own id opts out. FormDescription renders a muted <p>.
FormBody / FormSection props
FormBody is a flex column (a wrapping row under layout="inline"). FormSection is a semantic <section> with optional title (<h3>) and description — lighter than Fieldset (no native disabled cascade, no legend semantics).
FormRow props
| Prop | Type | Default | Description |
|---|---|---|---|
stackOnMobile | boolean | true | Stacks children vertically below sm |
weights | number[] | — | Relative column widths, one per child ([2, 1, 1]) |
FormFooter props
| Prop | Type | Default | Description |
|---|---|---|---|
align | "start" | "center" | "end" | "between" | "end" | Horizontal alignment. Use start on full-page forms, end in dialogs/cards |
sticky | boolean | false | Pins the bar to the bottom of the scroll context with a hairline, background, and safe-area padding |
Action tiers. The submit is primary (filled), the cancel/reset action beside
it is secondary — the filled neutral, not ghost. A ghost cancel reads as a
third tier, so a two-button footer using it renders one filled button and one that
looks like plain text. Reserve ghost for a genuine tertiary action set apart
from the pair (a destructive Delete anchored at the far side under
align="between"), where the drop in emphasis is the point.
FormErrorSummary props
| Prop | Type | Default | Description |
|---|---|---|---|
title | ReactNode | "There was a problem" | Heading above the list |
level | 1–4 | 3 | Heading level |
errors | FormErrors | context | Override the context errors |
fieldLabels | Record<name, ReactNode> | — | Prefix messages with visible field names |
Renders nothing while there are no errors. Each entry is a button that moves focus to the control whose name matches. FormRoot focuses the summary when errors arrives (see focusOnError).
FormSubmitError props
| Prop | Type | Default | Description |
|---|---|---|---|
intent | FormIntent | "error" | Colour palette and default icon |
title | ReactNode | — | Bold title above the body text |
hideIcon | boolean | false | Render without the leading icon |
FormHelperText props
| Prop | Type | Default | Description |
|---|---|---|---|
intent | FormIntent | inherited | Override the colour for this instance |
leadingIcon | ReactNode | — | Icon rendered before the text |
FormDivider / FormSkeleton
FormDivider is a 1px separator (role="separator"). FormSkeleton mirrors the real form's chrome — variant, size, rounded, layout, fields, footerActions, showHeader/showDescription/showFooter — deriving every box from the same maps the live parts read (§2b).
Accessibility
- Named landmark. With a
FormTitle, the<form>getsaria-labelledbyautomatically, so screen-reader users find it in the landmarks list under its visible name. - Server errors (WCAG 3.3.1). Render
FormErrorSummaryabove the body: it isrole="alert", receives focus when theerrorsmap arrives, and its entries move focus to each field. Inline messages come from Base UI's Field wiring (aria-invalid+aria-describedby). In an SPA, also prefixdocument.titlewith "Error: " on a failed submit. - Busy without lock-out (WCAG 4.1.3).
isSubmittingannounces through a pre-rendered politerole="status"region and never hard-disables controls — a disabled fieldset mid-submit throws keyboard focus to<body>and hides the form from assistive tech. Usedisabledonly for a truly inert form, and note its own caveat: children of a disabled fieldset are skipped in the tab order entirely. - Validation timing. Default is submit-first ("validate late"); after a failed submit Base UI re-validates on change. Blur/change modes are opt-in.
- Multi-step. On a step change, move focus to the new step's heading (
tabIndex={-1}), keep "Step n of N" in the heading text, and mark progress indicators witharia-current="step". - AT matrix. Keyboard + ARIA tree verified in Chromium (jsdom + Playwright); VoiceOver spot-checked locally. NVDA and JAWS are untested on this machine and are listed as such rather than claimed.
Agent notes
Every part carries a stable data-slot (form-root, form-header, form-title, form-description, form-required-legend, form-body, form-section, form-row, form-divider, form-footer, form-error-summary, form-submit-error, form-helper-text, form-status, form-skeleton). Busy state is observable as data-submitting on the root; the disabled state as data-disabled. The status region (form-status) always exists — assert announcements by reading its text, not by waiting for live-region events.
Composition tips
FormRowis for short fields side-by-side; useweightsfor checkout-style unequal columns. For full vertical stacks, useFormBodydirectly.FormSectionandFieldsetoverlap —FormSectionis the lighter semantic group;Fieldsetbrings nativedisabledpropagation and legend semantics. The cascade passes through both.- The submit handler receives parsed form values via Base UI's typed callback. Surface async results through
errors(field-level) andFormSubmitError(top-level), and announce success throughstatusMessage. - Don't gate the submit button on validity — validate on submit and show the summary instead (GOV.UK/Primer stance).
- Keep cognitive load down (NN/g): chunk fields into
FormSections, defer optional fields behind aCollapsible, and cap the measure withmaxWidth.
Migration (TES-7)
intent="primary"is deprecated — renders unchanged for one minor, warns in dev. Move tointent="none"(+ your own accent class if needed).rounded="full"now rendersrounded-3xl(24px, §2 panel rule) instead ofrounded-2xl.isSubmittingno longer disables the internal fieldset or blocks pointer events; theopacity-80dim is gone. Pair it with<Button isLoading type="submit">and ignore repeat submits in your handler.- The cascade now reaches Fieldset, TimePicker, Tokenizer, OtpInput, FileUpload, Rating, Stepper, DatePicker, Picker, PromptInput, SegmentedControl, ColorPicker (trigger), and gives
roundedto the Checkbox/Radio/Slider/NumberField/ToggleGroup bucket — existing forms containing those will see them finally follow the Form'ssize/rounded.
Notes
- Built on
@base-ui/react/form. Errors flow through Base UI'sFormContextto descendantField.Roots — setnameon each Field that should map to a server error key. A field's server error clears when the user edits that field. - Client-side validation focus (first invalid control) is Base UI's own behavior;
focusOnErrorgoverns the servererrorspath. - For multi-step forms, render one
FormRootper step and switch with state — each step gets its ownonFormSubmit.