DocumentationComponentsTheme Creator
Theme CreatorGitHubIntroduction
InstallationUsageThemingMotion
Components
FieldFieldsetFormFile UploadColor PickerMediaTrimmer
AI agentsContributing
  1. Components
  2. Form

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.

$ pnpm add @tessinaui/ui

When to use

  • A form-shaped task with a submit: sign-in, sign-up, profile, settings, checkout, onboarding. FormRoot gives it one submit handler and a named landmark.
  • Related fields that belong together. Chunk them into FormSections and put only short fields side by side in a FormRow; everything else stacks in one column.
  • Server errors that must reach the right field: pass an errors map keyed by field name and render FormErrorSummary so focus lands on the problem.
  • One size and one corner radius for every control. Set size and rounded on the root and the controls follow.
  • A submit that takes a moment: isSubmitting announces the busy state without locking the controls.
  • Only the questions you need. Ask for what the task requires, and mark the minority with requiredMark so people know what is expected.

When not to use

  • For a group of controls with no submit of its own. Use Fieldset, which names the group and passes disabled down.
  • For one labelled input. Use Field on its own.
  • To lay out content that is not a form. Use Stack, or Grid for a two-dimensional layout.
  • To gate the submit button on validity. Validate on submit and show the summary instead: a disabled submit says only that something is wrong, never what.
  • For one long form with no breaks. Split a long journey into steps, one FormRoot per step, rather than stacking everything in a single form.

Installation

Terminal
pnpm add @tessinaui/ui

Usage

example.tsx
import {
  FormRoot,
  FormHeader,
  FormTitle,
  FormDescription,
  FormBody,
  FormSection,
  FormRow,
  FormDivider,
  FormFooter,
  FormErrorSummary,
  FormSubmitError,
  FormHelperText,
} from "@tessinaui/ui";
example.tsx
<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>

What FormRoot bundles

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 (isSubmitting sets aria-busy and announces politely — without locking controls)
  • Server-side error reporting (errors map keyed by field name, FormErrorSummary with focus management, FormSubmitError for top-level failures)

For a single grouped set of controls without form-level submission, use Fieldset. For an individual labeled input, use Field.

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. filled is a card for a grouped page: put it on bg-muted in light and bg-background in dark, and it reads one step above the page in both themes — white on the grey page in light, a raised grey on the dark page. Its outline fields take the card's own colour, so the border is their only boundary. Give its fields variant="filled" for grey wells with no outline until focus or an error.

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 a horizontal form specifies a track instead — a ragged left edge on the controls is what makes a long form hard to scan. 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: 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. On a plate, variant="filled" draws a hairline instead of a wash: its fields already paint the 8% well, and a second wash under their text would read under AA on the error, info and success plates.

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.

RTL

dir="rtl" lands on the <form> and every child inherits it: the horizontal layout's label column moves to the right and end-aligned footer buttons gather on the left. Unset, FormRoot stamps no dir and the form inherits the page's direction.

The form's own text follows an explicit dir: under dir="rtl" the required legend, the submitting announcement and the error summary's heading are Arabic. For another right-to-left language, pass strings with every entry.

API Reference

FormRoot props

PropTypeDefaultDescription
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"—Text direction. Unset, it inherits the page's 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
requiredLegendReactNode | nullderivedOverride (or suppress) the legend line. Wins over strings
maxWidthnumber | string—Measure cap applied as inline max-width
validationMode"onSubmit" | "onBlur" | "onChange""onSubmit"When fields validate. Field.Root's own validationMode always wins
errorsRecord<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
actionsRefRefObject<{ validate(name?) }>—Imperative validation
isSubmittingbooleanfalseSets aria-busy + data-submitting and announces submittingLabel. Controls stay operable — pair with <Button isLoading type="submit"> and ignore repeat submits
submittingLabelReactNode"Submitting"Announced through the polite status region while busy. Arabic under dir="rtl"
statusMessageReactNode—Announce a result ("Changes saved") through the same region
disabledbooleanfalseDisables every control via a native <fieldset disabled>
stringsPartial<FormStrings>FORM_STRINGS by dirOverrides single entries of the form's own text: errorSummaryTitle, submitting, requiredAsterisk, requiredOptional. Reaches every FormErrorSummary inside the form; submittingLabel, requiredLegend and the summary's title still win

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. A radio's ring ignores rounded and stays a circle, so a form's radios never take the shape of its checkboxes; rounded still shapes RadioCard shells
  • 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 at lg), ColorPicker (triggerSize on the Button scale — its own size is a panel width)
  • labelWidth: cascades to the same set as labelPlacement, and only when you set it on FormRoot.
  • 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

PropTypeDefaultDescription
stackOnMobilebooleantrueStacks children vertically below sm
weightsnumber[]—Relative column widths, one per child ([2, 1, 1])

FormFooter props

PropTypeDefaultDescription
align"start" | "center" | "end" | "between""end"Horizontal alignment. Use start on full-page forms, end in dialogs/cards
stickybooleanfalsePins 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

PropTypeDefaultDescription
titleReactNode"There was a problem"Heading above the list. Follows the FormRoot's dir and strings; outside a FormRoot, English unless the summary has its own dir
level1–43Heading level
errorsFormErrorscontextOverride the context errors
fieldLabelsRecord<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, and is a 44px target on a touch screen (24px with a mouse). FormRoot focuses the summary when errors arrives (see focusOnError).

FormSubmitError props

PropTypeDefaultDescription
intentFormIntent"error"Colour palette and default icon
titleReactNode—Bold title above the body text
hideIconbooleanfalseRender without the leading icon

FormHelperText props

PropTypeDefaultDescription
intentFormIntentinheritedOverride the colour for this instance
leadingIconReactNode—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). Its label is what the loading region announces: "Loading" by default, Arabic under dir="rtl".

Accessibility

  • Named landmark. With a FormTitle, the <form> gets aria-labelledby automatically, so screen-reader users find it in the landmarks list under its visible name.
  • Server errors (WCAG 3.3.1). Render FormErrorSummary above the body: it is role="alert", receives focus when the errors map 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 prefix document.title with "Error: " on a failed submit.
  • Busy without lock-out (WCAG 4.1.3). isSubmitting announces through a pre-rendered polite role="status" region and never hard-disables controls — a disabled fieldset mid-submit throws keyboard focus to <body> and hides the form from assistive tech. Use disabled only 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 with aria-current="step".
  • AT matrix. Keyboard + ARIA tree verified in Chromium (jsdom + Playwright); VoiceOver + Safari passed 2026-09-14: the form reads "Create account, form" and "heading level 2, Create account", and a failed submit announces the summary, "There was a problem. Email: Enter an email address in the correct format, like name@example.com. Password: Password must be at least 12 characters". 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

  • FormRow is for short fields side-by-side; use weights for checkout-style unequal columns. For full vertical stacks, use FormBody directly.
  • FormSection and Fieldset overlap — FormSection is the lighter semantic group; Fieldset brings native disabled propagation 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) and FormSubmitError (top-level), and announce success through statusMessage.
  • Don't gate the submit button on validity — validate on submit and show the summary instead. A disabled submit says only that something is wrong, never what, and leaves a keyboard user with nothing to act on.
  • Keep cognitive load down: chunk fields into FormSections, defer optional fields behind a Collapsible, and cap the measure with maxWidth.

Migration

  • intent="primary" is deprecated — renders unchanged for one minor, warns in dev. Move to intent="none" (+ your own accent class if needed).
  • rounded="full" now renders rounded-3xl (24px, §2 panel rule) instead of rounded-2xl.
  • isSubmitting no longer disables the internal fieldset or blocks pointer events; the opacity-80 dim 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 rounded to the Checkbox/Radio/Slider/NumberField/ToggleGroup bucket — existing forms containing those will see them finally follow the Form's size/rounded.

Notes

  • Built on @base-ui/react/form. Errors flow through Base UI's FormContext to descendant Field.Roots — set name on each Field that should map to a server error key. A field's server error clears when the user edits that field.
  • A control with no name of its own (a DatePicker) marks its wrapper data-field-name="<key>"; the summary entries and focusOnError find it there, in its place in the form.
  • Client-side validation focus (first invalid control) is Base UI's own behavior; focusOnError governs the server errors path.
  • For multi-step forms, render one FormRoot per step and switch with state — each step gets its own onFormSubmit.
FieldsetFile Upload

On this page

When to useWhen not to useInstallationUsageWhat FormRoot bundlesExamplesDefaultVariantsSizesCascadeHorizontal layoutInline layoutRow weightsSectionsRequired-mark policyError summaryServer errorsValidation modesAsync submitSubmit errorFooter actionsIntentsAccount flowOn colorMulti-stepLoadingRTLAPI ReferenceFormRoot propsThe cascadeFormHeader / Title / DescriptionFormBody / FormSection propsFormRow propsFormFooter propsFormErrorSummary propsFormSubmitError propsFormHelperText propsFormDivider / FormSkeletonAccessibilityAgent notesComposition tipsMigrationNotes