DocumentationComponentsTheme CreatorGitHub
Theme CreatorGitHubIntroduction
InstallationUsageTheming
ComponentsAccordionAction SheetAlertAlertDialogArea ChartAspectRatioAvatarBadgeBannerBar ChartBottom NavBreadcrumbButtonButtonGroupCalendarCardCarouselChartChatBubbleChatBubbleNewCheckboxChipCoachMarkCodeBlockCollapsibleColor PickerComboboxCommandContainerContextMenuDate PickerDividerDrawerDropdown MenuEmptyStateFABFieldFieldsetFile UploadFlexFormGridHoverCardIconButtonLabelLine ChartLinkMenubarMeterModalNavigation MenuNumberFieldOTP InputPaginationPickerPie ChartPopoverProgressPromptInputRadar ChartRadial ChartRadioRatingScroll AreaSearchSegmentedControlSelectShortcutSidebarSkeletonSliderCircularSliderMediaTrimmerSpacerSpinnerSplit ButtonStackStatusStepperSurfaceSwitchTableTabsTextareaTime PickerToastToggleButtonToggleGroupTokenizerToolbarTooltipTop Header DesktopTop Header Mobile
Contributing
Components

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/ui

Usage

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 (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.

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""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
requiredLegendReactNode | nullderivedOverride (or suppress) the legend line
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
statusMessageReactNode—Announce a result ("Changes saved") through the same region
disabledbooleanfalseDisables 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 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
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. 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).

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

  • 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 (GOV.UK/Primer stance).
  • Keep cognitive load down (NN/g): chunk fields into FormSections, defer optional fields behind a Collapsible, and cap the measure with maxWidth.

Migration (TES-7)

  • 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.
  • 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.
FlexGrid

On this page

PlaygroundInstallationUsageExamplesDefaultVariantsSizesCascadeHorizontal layoutInline layoutRow weightsSectionsRequired-mark policyError summaryServer errorsValidation modesAsync submitSubmit errorFooter actionsIntentsAccount flowOn colorMulti-stepLoadingWhen to useAPI ReferenceFormRoot propsThe cascadeFormHeader / Title / DescriptionFormBody / FormSection propsFormRow propsFormFooter propsFormErrorSummary propsFormSubmitError propsFormHelperText propsFormDivider / FormSkeletonAccessibilityAgent notesComposition tipsMigration (TES-7)Notes