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.
pnpm add @tessinaui/uiWhen to use
- Related controls that share one label. Notification preferences, permission lists, consent categories, plan selection: a legend names the group once, so each control can keep a short label.
- A group-level description, helper or error.
errorMessagejoins the group'saria-describedbyand replaces the helper in place, so one message covers the whole set. - Disabling a set together.
disabledrenders the native attribute and cascades onto the known control children. - A settings card. The
outline,filledandelevatedvariants make the group a panel;layout="annotated"puts the header beside the rows on wide screens. - A page that asks one question. Put the heading inside the legend so the page heading and the group's name are the same node (Question page).
- Sub-groups inside a group. Nested fieldsets take the compact
variant="label"legend.
When not to use
- For a single labelled input. Use Field; the label already names it.
- To draw a box. If you only want a border or background around unrelated content, use a Card or a Surface. A fieldset is a grouping with meaning, not decoration.
- For a grid of checkboxes under column headers. That is a Table job.
- Around a CheckboxGroup or RadioGroup that already has its own label. They name themselves; wrap them only when the fieldset adds a shared description or error (Composition).
- For a group that folds away. Compose Collapsible inside the body (Collapsible group).
- For a saved-form workflow around many fieldsets. That belongs to Form.
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 — 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 15rem start column beside the content — the classic settings-page shape — once the fieldset itself is at least 36rem wide, and stacks when it is narrower. It follows its own width, not the screen's, so it also stacks in a narrow column on a wide screen.
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 one-question-per-page 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.
On color
A ghost fieldset paints no panel, so inside a coloured Surface its legend and description take the plate's inks and its controls inherit on-color. outline, filled and elevated paint a page panel and hand their content the default tone.
Skeleton
Silent by default; pass label for the one announced loading region.
RTL
dir="rtl" aligns the legend, description and helper text right and moves the header's trailing action to the left edge. Controls inside inherit the direction without a dir of their own. Unset, dir is not stamped and the fieldset follows the page.
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. filled is a card for a grouped page — put it on bg-muted in light and bg-background in dark |
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 once the fieldset is 36rem wide; stacks when narrower |
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 |
strings | Partial<FieldsetStrings> | English, Arabic under dir="rtl" | Override the legend's markers: required (the screen-reader-only word) and optional ("(optional)") |
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 |
strings | Partial<FieldsetStrings> | the FieldsetRoot's | Override required and optional for this legend only |
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 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. The fieldset's rounded shapes its own card only: switches, checkboxes and radios keep their own shape at every value.
Touch rhythm. On a touch screen — a phone, or a coarse pointer at any width — 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 with a mouse or trackpad from md up, where no floor applies, and any other child (a Field, a nested Fieldset, raw markup) keeps it everywhere, 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 (the fieldset gap on VO-iOS is documented 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, 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) — a well-documented 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.
- VoiceOver + Safari passed 2026-09-14: the first switch reads "Email, on, switch", then the group, "Notification preferences, Choose how you want to be notified about updates., group"; Space on the next switch reads "on, SMS, switch". 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.- The 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: a form-group wrapper is usually an invisible a11y wrapper; this component is also the settings-card unit.