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.

Without the legend, a screen reader announces "Monthly, radio button" and nothing else
Billing period
<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.

A .field-row inside a fieldset behaves exactly as it would anywhere else
Billing address
<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.

One attribute, every control inside it disabled
Shipping — unavailable for digital orders
<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.

Classes, generated from the stylesheet source
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

Design tokens this component reads
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.
  • disabled is 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-only on 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.

The legend 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 .field with a <label>.
  • Not as a generic bordered box. Use .card or .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.