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.

Three equal thirds, whatever the labels say
<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).

Real radios: arrow keys work, and it submits with the form
View
<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.

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

Design tokens this component reads
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-only on the radio, not display: 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-label on 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: nowrap is 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.