busy-office-ui
Menu

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

ClassRole
.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 one name — 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__input for the state selectors, .bo-visually-hidden to hide it visually while staying focusable (never display:none, which would drop it from the Tab order entirely). Its :focus-visible ring is moved onto the label sibling via CSS so it traces the visible segment.
  • Wrap the group in role="group" with an aria-label naming 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)
TextonLightDark
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
↑↓ move Enter open Esc close