Fieldset
.fieldset goes on a real <fieldset>: a border, a
radius, and a flex column with a
1rem gap inside. It exists for one
reason that no amount of styling can substitute for — its
<legend> is the accessible name of the group, and a group of
radios has no other way to get one.
When to use it
Use it whenever several controls answer one question. A radio group is the clearest case: each option has its own label, but nothing says what is being chosen until a legend does.
<fieldset class="fieldset" style="max-inline-size:26rem">
<legend>Billing period</legend>
<label class="check">
<input type="radio" name="fs-period" checked>
<span>Monthly</span>
</label>
<label class="check">
<input type="radio" name="fs-period">
<span>Annual — two months free</span>
</label>
</fieldset>
<fieldset class="fieldset" style="max-inline-size:26rem">
<legend>Billing period</legend>
<label class="check">
<input type="radio" name="fs-period" checked>
<span>Monthly</span>
</label>
<label class="check">
<input type="radio" name="fs-period">
<span>Annual — two months free</span>
</label>
</fieldset>
Why the legend cannot be a heading instead
A <h3> above a group of radios looks identical and does nothing.
Headings label a region of a document; a legend labels a group of form
controls, and screen readers use it differently — the legend is announced again
with each option, so a reader arrowing through the group hears "Billing period,
Monthly" and "Billing period, Annual" rather than losing the context after the first.
That repetition is the point, and it is the thing a heading cannot do. It is also why a legend should be short: it is read many times.
.label above a radio group is the same mistake in a different hat. It
looks like a group label and is a label for nothing — for can only point
at one control, and a group is not a control.
Styling a legend
<legend> is one of the last elements in HTML that resists CSS. It
is positioned by the browser, breaks the fieldset's border where it sits, and ignores
most layout properties. Deck does very little to it deliberately —
padding-inline, a small size, a muted colour and a heavier weight:
.fieldset > legend {
padding-inline: var(--space-2);
font-size: var(--text-sm);
font-weight: 600;
color: var(--text-muted);
}
The padding-inline is what stops the border running into the text on
either side. Everything else is typography.
If you need a legend that behaves like an ordinary block — to put a control beside it,
or to lay it out with flex — the usual approach is
<fieldset> with a visually hidden legend for the accessible name,
plus a <div> that carries the visible heading. Two elements, but
both do a job they are capable of.
Fields inside a fieldset
A fieldset is a flex column, so .field children
space themselves without margins — the same arrangement as a
.stack, with a
1rem gap rather than the stack's
default.
<fieldset class="fieldset" style="max-inline-size:26rem">
<legend>Billing address</legend>
<div class="field">
<label class="label" for="fs-street">Street</label>
<input class="input" id="fs-street">
</div>
<div class="field-row">
<div class="field">
<label class="label" for="fs-city">City</label>
<input class="input" id="fs-city">
</div>
<div class="field">
<label class="label" for="fs-post">Postcode</label>
<input class="input" id="fs-post">
</div>
</div>
</fieldset>
<fieldset class="fieldset" style="max-inline-size:26rem">
<legend>Billing address</legend>
<div class="field">
<label class="label" for="fs-street">Street</label>
<input class="input" id="fs-street">
</div>
<div class="field-row">
<div class="field">
<label class="label" for="fs-city">City</label>
<input class="input" id="fs-city">
</div>
<div class="field">
<label class="label" for="fs-post">Postcode</label>
<input class="input" id="fs-post">
</div>
</div>
</fieldset>
min-inline-size: 0 is set on the fieldset for the same reason it is set
on .field and .split > *: a flex or grid child's minimum
is its content, and a wide child would otherwise push the fieldset past its
container.
Disabling a whole group
<fieldset disabled> disables every control inside it, including
ones added later. That is a genuinely useful HTML feature with no CSS equivalent —
and it is the browser doing it, so the controls are removed from the tab order
properly rather than just looking grey.
<fieldset class="fieldset" disabled style="max-inline-size:26rem">
<legend>Shipping — unavailable for digital orders</legend>
<div class="field">
<label class="label" for="fs-addr">Address</label>
<input class="input" id="fs-addr" value="Not required">
</div>
<label class="check">
<input type="checkbox">
<span>Signature on delivery</span>
</label>
</fieldset>
<fieldset class="fieldset" disabled style="max-inline-size:26rem">
<legend>Shipping — unavailable for digital orders</legend>
<div class="field">
<label class="label" for="fs-addr">Address</label>
<input class="input" id="fs-addr" value="Not required">
</div>
<label class="check">
<input type="checkbox">
<span>Signature on delivery</span>
</label>
</fieldset>
Deck does not style .fieldset[disabled] — the controls inside get their
own disabled treatment, but the border and legend look unchanged. Adding
opacity to the group would make the whole thing clearer, and it is
recorded in FINDINGS.md. Note that a disabled fieldset's controls do not
submit, which is often exactly what you want.
Classes
.fieldset has no .fieldset-* variants, so the extractor
does not treat it as a component root and there is no completeness check on this
page. Its second rule, .fieldset > legend, is an element selector.
| Class | Layer | What it does | Source |
|---|---|---|---|
.fieldset |
deck.components |
A bordered group of controls that answer one question. Its legend is the group's accessible name, which is what a radio group needs and what a .label above one does not provide. | 06-forms.css:351 |
Tokens
| Token | Live | Declared value |
|---|---|---|
--line |
light-dark(var(--ink-200), oklch(28% .016 var(--hue-neutral))) |
|
--r-md |
12px |
|
--space-2 |
.5rem |
|
--space-4 |
1rem |
|
--space-5 |
1.25rem |
|
--text-sm |
.8125rem |
|
--text-muted |
light-dark(var(--ink-600), var(--ink-400)) |
Accessibility
-
This is an accessibility component first. Everything else about it
is a border. A radio group without a legend is the most common serious form defect
there is, and no class can fix it — the element has to be a
<fieldset>and it has to contain a<legend>. -
The legend must be the first child. HTML requires it, and browsers
that tolerate it elsewhere will not associate it. Deck's selector is
.fieldset > legend, so a legend nested inside a wrapper is not styled either — which is a useful early warning. - Keep it short. The legend is announced with every control in the group, so a sentence becomes a sentence repeated five times.
- Do not use one for every field. A fieldset around a single input gives that input two names — the legend and its label — and a screen reader reads both. Fieldsets are for groups.
-
disabledis real and thorough. It disables descendants the browser adds later too, and removes them from the tab order. The one exception is a control inside the first<legend>, which stays enabled by specification. -
A hidden legend still works. If the visible design has no room for
one,
.sr-onlyon the legend keeps the accessible name while removing it from the screen. That is far better than omitting it.
Right to left
The legend's padding-inline is logical and the border is symmetrical, so
the legend moves to the right edge under dir="rtl" with no extra rules —
the browser positions it at the start of the block, and the start follows the writing
direction.
<fieldset dir="rtl" class="fieldset" style="max-inline-size:26rem">
<legend>فترة الفوترة</legend>
<label class="check"><input type="radio" name="fs-rtl" checked><span>شهري</span></label>
<label class="check"><input type="radio" name="fs-rtl"><span>سنوي</span></label>
</fieldset>
<fieldset dir="rtl" class="fieldset" style="max-inline-size:26rem">
<legend>فترة الفوترة</legend>
<label class="check"><input type="radio" name="fs-rtl" checked><span>شهري</span></label>
<label class="check"><input type="radio" name="fs-rtl"><span>سنوي</span></label>
</fieldset>
Reduced motion
Nothing about a fieldset animates or transitions, so
prefers-reduced-motion changes nothing here. The controls inside it have
their own behaviour — see checkbox and radio.
Printing
src/99-print.css lists .fieldset with the other surfaces —
a #bbb hairline, no radius, a white background, and
break-inside: avoid. That last one matters most here: a group of
controls split across two sheets loses the connection between its legend and half its
options, which is the printed version of the defect this component exists to prevent.
Overriding it
@layer app.components {
/* No box, just the grouping and the legend */
.fieldset-bare { border: 0; padding: 0; }
.fieldset-bare > legend { padding-inline: 0; }
/* Make a disabled group visibly disabled */
.fieldset[disabled] { opacity: .6; }
}
.fieldset-bare is worth having: the accessibility benefit is entirely in
the element and the legend, so you can drop the border and keep all of it. Deck ships
the bordered version because a visible group is usually the point of grouping.
When not to use it
-
Not around a single control. Two names for one input. Use
.fieldwith a<label>. -
Not as a generic bordered box. Use
.cardor.panel. A fieldset around non-form content is invalid and adds a group semantic that means nothing. -
Not for visual sectioning of a long form. If the grouping is about
reading order rather than about one question, use headings and
.stack. A page of six bordered boxes is harder to scan than a page with six headings. - Not with a legend you need to lay out. The element resists CSS. Use a hidden legend plus a visible heading rather than fighting it.
- Not nested more than one deep. Nested fieldsets are legal and announced as nested groups, which gets confusing quickly — a reader hears both legends before every option.