Fieldset
Groups related form controls under a shared legend, description, helper and error text — a native fieldset with real disabled cascade, aria-describedby wiring, card variants, and annotated settings layout.
Playground
Installation
pnpm add @tessinaui/uiUsage
import {
FieldsetRoot,
FieldsetHeader,
FieldsetLegend,
FieldsetDescription,
FieldsetBody,
FieldsetHelperText,
} from "@tessinaui/ui";<FieldsetRoot variant="outline">
<FieldsetHeader>
<FieldsetLegend required>Notification preferences</FieldsetLegend>
<FieldsetDescription>
Choose how you want to be notified about updates.
</FieldsetDescription>
</FieldsetHeader>
<FieldsetBody>
<Switch label="Email" defaultChecked />
<Switch label="SMS" />
<Switch label="Mobile push" />
</FieldsetBody>
<FieldsetHelperText>
You can change these later in account settings.
</FieldsetHelperText>
</FieldsetRoot>Examples
Default
A grouped set of switches under a shared legend and description.
Variants
Card treatments — outline, filled, and elevated (the default ghost is structure only). Space alone groups; reach for a card only when the page needs the stronger separation.
Sizes
Five sizes from xs to xl, scaling the legend, text, gap, and padding together — and cascading onto Switch, Checkbox and Radio children.
Intents
Intent drives the border accent (an inset ring on filled, so nothing shifts) and the default HelperText colour.
States
disabled renders the native <fieldset disabled> attribute and cascades onto known control children; errorMessage swaps the helper text for a politely-announced error.
Validation
Group-level errorMessage joins the fieldset's aria-describedby, implies the error intent, and replaces the helper in place (M3/Carbon swap) — plus a visually hidden legend that still names the group.
Header actions
The Header trailing slot: a master switch arming the group, a reset action, or a count badge.
Divided rows
dividers on the Body swaps the open gap for rules between rows — the settings-list rhythm.
Annotated layout
layout="annotated" puts the header in a start column beside the content from md up — the classic settings-page shape — and stacks on phones.
Nested groups
Nested fieldsets carry compact variant="label" legends, so sub-groups read as one section.
Footer actions
FieldsetActions is the Save/Cancel row at the group's bottom.
Collapsible group
Compose the house Collapsible around the body — the legend stays visible, only the rows fold.
Consent categories
The cookie-preferences shape: a locked "always active" row (readOnly Switch with a status word), editable categories, footer actions.
Question page
The GOV.UK question shape: a heading inside the legend, so the page heading and the group's accessible name are the same node.
Composition
CheckboxGroup and RadioGroup compose naturally inside the body.
Skeleton
Silent by default; pass label for the one announced loading region.
When to use
Reach for Fieldset when a group of related controls shares one label, description, or validation message — notification preferences, permission lists, consent categories, plan selection. For a single labeled input, use Field. A grid of checkboxes under column headers is a Table job. A group that folds away composes Collapsible; a saved-form workflow around many fieldsets belongs to Form.
API Reference
FieldsetRoot props
| Prop | Type | Default | Description |
|---|---|---|---|
size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | Scales legend, description, helper text, gap, padding — and child controls via the cascade |
variant | "ghost" | "outline" | "filled" | "elevated" | "ghost" | ghost is structure only; the others wrap everything in a card |
rounded | "none" | "sm" | "md" | "lg" | "full" | "md" | Card corner radius (non-ghost). full is the 24px panel radius, per the house §2 panel rule |
intent | "none" | "error" | "warning" | "success" | "info" | "none" | Border accent (inset ring on filled) + default HelperText colour. "primary" still renders as a deprecated alias for one minor and warns in dev |
layout | "stacked" | "annotated" | "stacked" | annotated puts the header beside the content from md up; stacks on phones |
orientation | "vertical" | "horizontal" | "vertical" | Default layout for FieldsetBody children |
disabled | boolean | false | Renders the native <fieldset disabled> attribute and clones disabled onto known control children — see the cascade note below |
invalid | boolean | false | Error styling without any message text |
errorMessage | ReactNode | — | Group error: swaps the helper slot, announces politely, joins aria-describedby, implies the error intent unless another non-none intent is set |
dir | "ltr" | "rtl" | inherited | Only set to force a direction; unset inherits from the document |
render | Base UI render prop | — | Replaces the rendered element — the replacement then owns its own attributes, including native disabled |
FieldsetLegend props
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "legend" | "label" | "legend" | label is the compact field-label scale for nested sub-groups |
required | boolean | false | Decorative * plus a screen-reader-only " required" in the group name |
optional | boolean | false | Muted "(optional)" joined into the group name. Ignored when required |
visuallyHidden | boolean | false | Hides the legend visually; the group keeps its accessible name |
leadingIcon | ReactNode | — | Icon before the legend text |
Renders Base UI's Fieldset.Legend — a <div> the fieldset points at via aria-labelledby. See Accessibility for what that means in practice.
FieldsetHeader props
| Prop | Type | Default | Description |
|---|---|---|---|
trailing | ReactNode | — | Master switch, action button, or count badge at the legend row's end |
FieldsetBody props
| Prop | Type | Default | Description |
|---|---|---|---|
orientation | "vertical" | "horizontal" | inherited | Override the Root's orientation for this body only |
dividers | boolean | false | Rules between rows instead of the open gap (vertical only). Use sparingly |
The body also applies the cascade: size (and rounded for Switch) plus disabled are cloned onto direct children named Switch, Checkbox(Group/Card/CardGroup), Radio(Group/Card/CardGroup) — fragments are traversed, deeper wrappers are not. Explicit props on a child always win.
Phone rhythm. Below md, a vertical body whose children are all single control rows (Switch, Checkbox, Radio) drops its gap and stamps data-rows: those rows already carry a 44px touch floor, so a gap on top double-counts it — a 24px switch in a 44px row plus a 12px gap reads as 56px of pitch, the sparse list every phone platform avoids. The gap returns at md, and any other child (a Field, a nested Fieldset, raw markup) keeps it at every width, because those have no floor of their own. CheckboxGroup applies the same rule to its own rows.
FieldsetDescription · FieldsetHelperText · FieldsetErrorText
Description (muted, under the legend), HelperText (bottom meta row, intent-coloured, optional leadingIcon, hidden while the Root's errorMessage is present) and ErrorText (error-coloured row; rendered for you by errorMessage, composable when you own the layout). All three register their ids into the fieldset's aria-describedby.
FieldsetActions props
| Prop | Type | Default | Description |
|---|---|---|---|
align | "start" | "center" | "end" | "between" | "end" | Placement of the footer action row |
FieldsetSkeleton props
variant / size / rounded / intent / orientation mirror the root; control ("field" \| "switch"), fields, showLegend / showDescription / showHelper shape the placeholder; label opts into the single polite announcement — silent without it.
Accessibility
- The root is a real
<fieldset>(role="group"), named by the legend viaaria-labelledby. Base UI's legend is a<div>, not a native<legend>: desktop screen readers (VoiceOver macOS, NVDA, JAWS) resolve the name identically, but VoiceOver iOS and TalkBack are historically unreliable with group semantics either way (USWDS documents the fieldset gap on VO-iOS even for native legends). Keep individual control labels self-explanatory — "Email" under a "Notifications" legend still reads as "Email" alone on the weakest AT. Description,HelperTextandErrorTextjoin the fieldset'saria-describedby; consumer-suppliedaria-describedbyis merged first, never clobbered.errorMessagerenders inside a pre-mountedaria-live="polite"region, so the insertion announces without stealing focus. The helper is swapped out, not stacked (M3/Carbon), so nothing shifts beside an open keyboard.requiredon the legend adds a decorative star and the word "required" to the group name —role=groupaccepts noaria-required, and there is no single input to carry the native attribute.- Disabled coverage:
disabledrenders the native attribute (disabling native inputs, e.g. aField's<input>) and clonesdisabledonto the known control children listed under Body. A custom component with a differentdisplayName, or a control nested inside an extra wrapper<div>, receives neither — passdisabledyourself there. Anchor-styled buttons are never disabled by a fieldset (browser behaviour); Bootstrap documents the same trap. - A control in the header
trailingslot that arms the group (a master switch) must not sit inside a disabled scope it controls — disable the rows, not the root (see the Header actions example). - Keyboard: the group itself takes no focus; Tab order is the natural order of the controls inside.
- AT verified: VoiceOver macOS. NVDA and JAWS: untested (no Windows hardware in this loop).
Agent notes
Every part carries a stable data-slot (fieldset, fieldset-header, fieldset-legend, fieldset-description, fieldset-body, fieldset-helper, fieldset-error, fieldset-error-region, fieldset-actions, fieldset-header-trailing, fieldset-skeleton); the root also stamps data-invalid, data-disabled, data-layout, data-orientation, and the body data-rows when it holds only control rows. Target those, not class names. To read validation state: [data-slot="fieldset"][data-invalid], message text inside [data-slot="fieldset-error"].
Migration
| Before | After |
|---|---|
intent="primary" | Deprecated alias (renders + dev-warns for one minor) — use className for a brand accent |
rounded="full" → 16px | Now the 24px panel radius (rounded-3xl), per the house §2 rule |
dir forced "ltr" | Unset inherits the document direction; set only to force one |
| Docs claimed native disabled cascade | It now actually exists — native attribute + clone-cascade with the documented coverage above |
| Skeleton always announced "Loading" | Silent by default — pass label to announce |
Notes
Fieldset.Headeris optional — a bareFieldset.Legenddirectly in the root works.- Atlassian's required-fields convention fits the
Description: one line ("Required fields are marked with an asterisk *") instead of decorating every field. - The visually rich card variants are deliberate: Carbon's FormGroup and Atlaskit's Fieldset are invisible a11y wrappers; this component is also the settings-card unit.