Segmented
.segmented is a one-of-N control drawn as a sunken track with the
selected option raised out of it. Its children get
flex: 1 1 0, so every option is exactly the same width whatever its
label — which is what makes the row read as a single control rather than as a row of
buttons.
When to use it
Use it for two to four mutually exclusive options that the reader should be able to see all of at once: a date range, a view mode, a filter with a small fixed set of values. It is the compact alternative to a radio group, and it is particularly good on a phone, where a select would hide the choices behind a tap.
<div class="segmented" role="tablist" aria-label="Date range" style="max-inline-size:22rem">
<button class="btn btn-ghost" role="tab" aria-selected="true">Day</button>
<button class="btn btn-ghost" role="tab" aria-selected="false" tabindex="-1">Week</button>
<button class="btn btn-ghost" role="tab" aria-selected="false" tabindex="-1">Month</button>
</div>
<div class="segmented" role="tablist" aria-label="Date range" style="max-inline-size:22rem">
<button class="btn btn-ghost" role="tab" aria-selected="true">Day</button>
<button class="btn btn-ghost" role="tab" aria-selected="false" tabindex="-1">Week</button>
<button class="btn btn-ghost" role="tab" aria-selected="false" tabindex="-1">Month</button>
</div>
.segmented > * styles the children directly, so they need no class of
their own. The .btn.btn-ghost above is there to give them a focus ring and
a hover state; a bare <button> works too and is what the styling
assumes.
The selected state
The raised look comes from [aria-selected="true"] — a surface background,
the full text colour and a one-pixel shadow, against the sunken
--bg-sunken track. Nothing moves: the unselected children have the same
padding and radius, so selecting one is a colour and shadow change only.
.segmented > [aria-selected="true"], .segmented > .is-active {
background: var(--surface);
color: var(--text);
box-shadow: var(--shadow-1);
}
As with tabs, styling from the ARIA attribute means the visual
state cannot drift from the accessible one. .is-active is accepted for
cases where the ARIA attribute would be wrong — a set of links, for instance.
The version with no JavaScript
A segmented control is a radio group wearing different clothes, and it can be built as
one — real inputs, real names, real form submission, no script at all. Hide the input
and drive the appearance from :has(:checked).
<fieldset class="fieldset" style="max-inline-size:24rem;border:0;padding:0">
<legend class="label">View</legend>
<div class="segmented">
<label class="btn btn-ghost">
<input class="sr-only" type="radio" name="dx-seg" checked>
<span>List</span>
</label>
<label class="btn btn-ghost">
<input class="sr-only" type="radio" name="dx-seg">
<span>Board</span>
</label>
<label class="btn btn-ghost">
<input class="sr-only" type="radio" name="dx-seg">
<span>Calendar</span>
</label>
</div>
</fieldset>
<fieldset class="fieldset" style="max-inline-size:24rem;border:0;padding:0">
<legend class="label">View</legend>
<div class="segmented">
<label class="btn btn-ghost">
<input class="sr-only" type="radio" name="dx-seg" checked>
<span>List</span>
</label>
<label class="btn btn-ghost">
<input class="sr-only" type="radio" name="dx-seg">
<span>Board</span>
</label>
<label class="btn btn-ghost">
<input class="sr-only" type="radio" name="dx-seg">
<span>Calendar</span>
</label>
</div>
</fieldset>
Deck does not style the checked state of this version. The rule keys
off [aria-selected], not :has(:checked), so the labels above
look identical whichever radio is chosen. It is a real gap, recorded in
FINDINGS.md; one rule closes it:
.segmented > label:has(:checked) { background: var(--surface); color:
var(--text); box-shadow: var(--shadow-1); }
Until then, the radio version needs that rule in your own layer — but it is still worth preferring, because arrow-key navigation, form submission and the group label all come free from the elements.
Width
inline-size: 100% is set on the control, so a segmented control fills its
container by default. That suits a phone, where it usually spans the screen. Cap it
with a max-inline-size or put it in a narrower parent when it should not.
<div class="stack stack-3">
<div class="segmented" style="max-inline-size:16rem">
<button class="btn btn-ghost" aria-selected="true">On</button>
<button class="btn btn-ghost" aria-selected="false">Off</button>
</div>
<div class="segmented">
<button class="btn btn-ghost" aria-selected="true">Full width</button>
<button class="btn btn-ghost" aria-selected="false">By default</button>
</div>
</div>
<div class="stack stack-3">
<div class="segmented" style="max-inline-size:16rem">
<button class="btn btn-ghost" aria-selected="true">On</button>
<button class="btn btn-ghost" aria-selected="false">Off</button>
</div>
<div class="segmented">
<button class="btn btn-ghost" aria-selected="true">Full width</button>
<button class="btn btn-ghost" aria-selected="false">By default</button>
</div>
</div>
Classes
One class. The children are styled by .segmented > *, so there are no
.segmented-* members and no component root — hence no completeness check
on this page.
| Class | Layer | What it does | Source |
|---|---|---|---|
.segmented |
deck.components |
A one-of-N control: equal-width children in a sunken track with the selected one raised. Styled from [aria-selected="true"] or .is-active. Built from radio inputs it needs no JavaScript and gets arrow keys free — but Deck does not style :has(:checked), so that version needs one rule of your own. | 07-components.css:393 |
Tokens
| Token | Live | Declared value |
|---|---|---|
--bg-sunken |
light-dark(var(--ink-100), oklch(8.6% .012 var(--hue-neutral))) |
|
--surface |
light-dark(var(--ink-0), var(--ink-950)) |
|
--line |
light-dark(var(--ink-200), oklch(28% .016 var(--hue-neutral))) |
|
--text |
light-dark(var(--ink-900), var(--ink-100)) |
|
--text-muted |
light-dark(var(--ink-600), var(--ink-400)) |
|
--text-sm |
.8125rem |
|
--r-sm |
7px |
|
--shadow-1 |
0 1px 2px oklch(20% .03 var(--shadow-color) / .06), 0 1px 1px oklch(20% .03 var(--shadow-color) / .04) |
|
--space-2 |
.5rem |
|
--space-3 |
.75rem |
|
--dur-2 |
190ms |
Accessibility
-
Pick a pattern and commit to it. A segmented control can be a
tablist, a radio group, or a group of buttons — and each has different obligations.
Mixing them, or using
role="tablist"with no arrow keys, is worse than using none. -
As a tablist, it needs everything
the tabs page describes: roving
tabindex, arrow keys, Home and End. Deck ships none of it. -
As a radio group, the browser gives you arrow keys, the group label
from a
<legend>, and form submission — for free. This is the version to prefer, and the one Deck does not fully style. - The children are 36px tall, below the 44px touch target. That is a deliberate density choice for a control that is often full width — the horizontal target is generous even when the vertical one is not — but it is a third place Deck ships under the minimum, after the menu item and the data grid checkbox.
- The selected state is not colour alone. It has a background, a shadow and a text-colour change, so it survives without colour vision.
-
.sr-onlyon the radio, notdisplay: none. A hidden-with-display input is not focusable and not in the accessibility tree, which would take the arrow keys away — the same trap as the file input. -
Label the group.
aria-labelon the tablist version, a<legend>on the radio version. Without one the options are announced with no indication of what is being chosen.
Right to left
A flex row with a gap and padding-inline on the children, so it fills
from the right under dir="rtl" with no extra rules. The first option in
the markup is the first from the right, which is what a reader of Arabic expects.
<div dir="rtl" class="segmented" aria-label="النطاق الزمني" style="max-inline-size:22rem">
<button class="btn btn-ghost" aria-selected="true">يوم</button>
<button class="btn btn-ghost" aria-selected="false">أسبوع</button>
<button class="btn btn-ghost" aria-selected="false">شهر</button>
</div>
<div dir="rtl" class="segmented" aria-label="النطاق الزمني" style="max-inline-size:22rem">
<button class="btn btn-ghost" aria-selected="true">يوم</button>
<button class="btn btn-ghost" aria-selected="false">أسبوع</button>
<button class="btn btn-ghost" aria-selected="false">شهر</button>
</div>
Reduced motion
Children transition their background and colour over
190ms; the global reset collapses both to
.01ms under prefers-reduced-motion: reduce. There is no
sliding pill behind the selection, which would need JavaScript to measure — the
background simply moves from one child to another.
Printing
src/99-print.css lists .segmented among the chrome that
never prints, alongside .tabs and .pagination. It is a
control for choosing what to look at, and on paper the choice has already been made.
The same caveat as tabs: the printed page then carries no record of which option was selected. If the reader needs to know they were looking at Week rather than Month, that has to appear in the content as well as in the control.
Overriding it
@layer app.components {
/* Style the radio version, which Deck does not */
.segmented > label:has(:checked) {
background: var(--surface);
color: var(--text);
box-shadow: var(--shadow-1);
}
/* Natural widths rather than equal thirds */
.segmented-auto > * { flex: 0 1 auto; }
/* Full touch targets */
.segmented > * { min-block-size: var(--tap); }
}
When not to use it
- Not for more than about four options. Equal widths mean five options are five narrow columns, and the labels start truncating. Use a select, or a radio group in a fieldset.
-
Not for long labels.
white-space: nowrapis set, so a long option pushes the control wider rather than wrapping. One or two words each. - Not for actions. A segmented control shows a persistent choice. Three buttons that each do something are a cluster of buttons or a bar.
-
Not for an on/off setting. Two options where one is "off" is a
.switch, which is smaller and reads faster. - Not as a tablist without the keyboard work. Use the radio version instead — you get the arrow keys from the platform and the only thing missing is one CSS rule.