Segmented control
A toggle between 2-5 mutually exclusive views — My approvals vs Team
approvals, Week vs Month vs Quarter. The button-group alternative to a
select when every option should be visible
at once, not hidden behind a click. Real radio inputs under the hood
(two-channel: the filled segment is
the visible signal, native checked/group semantics are the
programmatic one) — zero JS.
Not past about five options, or when an option needs explaining
— the whole point is that every choice is visible at once, and a row that
wraps or gets truncated has lost it. Use a
select, or
Combobox when the list is long
enough to want filtering.
Basic — two options
View code
<div class="bo-segmented" role="group" aria-label="Approval scope">
<input class="bo-segmented__input bo-visually-hidden" type="radio" name="seg-scope" id="seg-mine" checked>
<label class="bo-segmented__option" for="seg-mine">My approvals</label>
<input class="bo-segmented__input bo-visually-hidden" type="radio" name="seg-scope" id="seg-team">
<label class="bo-segmented__option" for="seg-team">Team approvals</label>
</div> Click a segment, or Tab to the group and use arrow keys — real radio-group keyboard navigation, not reimplemented.
Three options
View code
<div class="bo-segmented" role="group" aria-label="Report range">
<input class="bo-segmented__input bo-visually-hidden" type="radio" name="seg-range" id="seg-week" checked>
<label class="bo-segmented__option" for="seg-week">Week</label>
<input class="bo-segmented__input bo-visually-hidden" type="radio" name="seg-range" id="seg-month">
<label class="bo-segmented__option" for="seg-month">Month</label>
<input class="bo-segmented__input bo-visually-hidden" type="radio" name="seg-range" id="seg-quarter">
<label class="bo-segmented__option" for="seg-quarter">Quarter</label>
</div> Saved views, with their size
View code
<div class="bo-segmented" role="group" aria-label="Saved views">
<input class="bo-segmented__input bo-visually-hidden" type="radio" name="seg-view" id="seg-all" checked>
<label class="bo-segmented__option" for="seg-all">All open <span class="bo-u-text-muted bo-u-tabular">128</span></label>
<input class="bo-segmented__input bo-visually-hidden" type="radio" name="seg-view" id="seg-blocked">
<label class="bo-segmented__option" for="seg-blocked">Blocked <span class="bo-u-text-muted bo-u-tabular">9</span></label>
<input class="bo-segmented__input bo-visually-hidden" type="radio" name="seg-view" id="seg-mine2">
<label class="bo-segmented__option" for="seg-mine2">Mine <span class="bo-u-tabular bo-u-text-muted">14</span></label>
</div> The count is usually the load-bearing half: "Blocked 9" is what makes a clerk click, while "Blocked" alone is a filter nobody knows the size of. It needs no part and no modifier — an option is a flex container with a gap, so a second child sits beside the label. Use muted tabular text, not a badge: a badge is a status chip and a count is not a status, and at compact density a badge is 24px tall inside a 24px segment — measured — so it fills the option edge to edge. Where there IS room and the number really is a status (a task tile, a nav row), the badge is right. Tabular figures keep the numbers from shifting the option's width as they change.
Disabled option
View code
<div class="bo-segmented" role="group" aria-label="Export format">
<input class="bo-segmented__input bo-visually-hidden" type="radio" name="seg-export" id="seg-csv" checked>
<label class="bo-segmented__option" for="seg-csv">CSV</label>
<input class="bo-segmented__input bo-visually-hidden" type="radio" name="seg-export" id="seg-pdf" disabled>
<label class="bo-segmented__option" for="seg-pdf">PDF (Pro)</label>
</div> A locked-behind-a-plan format, for example — disable the input, not the whole group.
Markup
<!-- A radiogroup, not styled buttons: keyboard arrow-navigation between
options, "n of m" announcement, and mutual exclusivity all come from
the platform. Inputs are .bo-visually-hidden (kept focusable), not
display:none — the ring moves to the label via the input's sibling
selector, so it traces the visible segment. -->
<div class="bo-segmented" role="group" aria-label="View">
<input class="bo-segmented__input bo-visually-hidden" type="radio" name="view" id="view-mine" checked>
<label class="bo-segmented__option" for="view-mine">My approvals</label>
<input class="bo-segmented__input bo-visually-hidden" type="radio" name="view" id="view-team">
<label class="bo-segmented__option" for="view-team">Team approvals</label>
</div> Quick reference generated
| Class | Role |
|---|---|
| .bo-segmented | block (root) |
| .bo-segmented__input | part: input |
| .bo-segmented__option | part: option |
API generated from CSS
| Blocks | bo-segmented |
|---|---|
| Parts | bo-segmented__inputbo-segmented__option |
| JS required | None — CSS-only. |
- A real
<input type="radio">per option, sharing onename— mutual exclusivity, keyboard arrow navigation, and screen-reader group semantics all come from the platform, not from JS. - The input carries BOTH classes —
.bo-segmented__inputfor the state selectors,.bo-visually-hiddento hide it visually while staying focusable (neverdisplay:none, which would drop it from the Tab order entirely). Its:focus-visiblering is moved onto the label sibling via CSS so it traces the visible segment. - Wrap the group in
role="group"with anaria-labelnaming what the options select (native radio groups have no accessible name of their own). - No more than ~5 options — beyond that, a select or dropdown reads better than a wide row of segments.
Contrast — 26 AA pairs this component uses (generated, CI-gated)
| Text | on | Light | Dark |
|---|---|---|---|
| text-primary | bg-surface | 17.74 | 16.15 |
| text-primary | bg-canvas | 16.98 | 18.08 |
| text-secondary | bg-surface | 10.31 | 11.46 |
| text-secondary | bg-muted | 9.37 | 9.75 |
| text-muted | bg-surface | 7.56 | 6.65 |
| text-muted | bg-muted | 6.87 | 5.66 |
| bg-surface | text-primary | 17.74 | 16.15 |
| danger-text | bg-surface | 6.47 | 8.89 |
| success-text | bg-surface | 5.02 | 12.02 |
| accent | bg-surface | 5.47 | 9.07 |
| text-primary | bg-hover | 16.12 | 13.75 |
| text-primary | bg-selected | 17.01 | 11.86 |
| text-secondary | bg-selected | 9.88 | 8.41 |
| text-primary | bg-surface-raised | 17.74 | 14.51 |
| text-secondary | bg-canvas | 9.86 | 12.82 |
| border-control | bg-surface | 4.83 | 6.65 |
| --bo-state-error-color | bg-surface | 4.83 | 6.1 |
| border-control | bg-muted | 4.39 | 5.66 |
| focus-ring | bg-canvas | 3.58 | 5.05 |
| accent | bg-muted | 4.97 | 7.72 |
| warning-strong | bg-muted | 4.56 | 6.69 |
| warning-strong | bg-surface | 5.02 | 7.86 |
| danger | bg-muted | 4.39 | 5.19 |
| text-primary | bg-muted | 16.12 | 13.75 |
| success-text | bg-muted | 4.56 | 10.23 |
| danger-text | bg-muted | 5.88 | 7.57 |
Maturity generated
- Introduced
- v0.3.0
- CSS floor
- Chrome/Edge 99 · Firefox 97 · Safari 15.4
- Alignment scored
- 2026-08-21
- Assistive-tech evidence
- None recorded
Pin this component's first release with npm i @busy-office/ui@0.3.0.
The floor is derived from this component's stylesheet alone — set by
@layer — so it sits at or below the
whole framework's Chrome/Edge 119 · Firefox 129 · Safari 17.5.
No assistive-technology runtime result is recorded for any component: it needs a human listening to VoiceOver and NVDA on owner hardware; no gate in this repo can simulate announcement behaviour.
What automation covers instead is test:axe across every page at 1440 and 390, and check:forced-colors — see
accessibility.
Design-system alignment generated
100% (18/18) — how closely this component follows the design language. Each rule is scored 0-3 against a checkable citation; rules that don't apply leave the denominator. Scored 2026-08-21.
| Rule | Score | Evidence |
|---|---|---|
| Typography | 3 / 3 | --bo-density-font-size; no raw font-size |
| Colour | 3 / 3 | zero raw colour |
| Spacing | 3 / 3 | the 4px inset is explained as the track padding on both edges, subtracted from the density alias so the group matches the inputs beside it at every density (94.6b) |
| Interaction | 3 / 3 | a real <input type="radio"> per option sharing one name, so mutual exclusivity, arrow-key navigation and group semantics all come from the platform rather than JS |
| Content | 3 / 3 | the opener names the boundary and the alternative: the button-group answer to a SELECT, for 2-5 options when every one should be visible at once |
| Fit | 3 / 3 | 2-5 mutually exclusive views; the page cross-names tabs and filters as the neighbouring shapes |