SegmentedControl

A compact row of two to five mutually exclusive options that applies immediately — a mode or view switch, not a form field. A RadioGroup in a toolbar's clothing.

Basic usage

Install the package:

pnpm add @design-schema/react @design-schema/tokens

Import the stylesheet once for the app, then the component:

import '@design-schema/tokens/calm-precise/css';
import '@design-schema/react/index.css';
import { SegmentedControl } from '@design-schema/react';

Examples

import { SegmentedControl, type SegmentedControlOption } from '@design-schema/react';

const OPTIONS: SegmentedControlOption[] = [
  { value: 'day', label: 'Day' },
  { value: 'week', label: 'Week' },
  { value: 'month', label: 'Month' },
];

<SegmentedControl label="Range" options={OPTIONS} />

Props

7 rows
PropTypeDefaultDescriptionPlatforms
label requiredstring—

Accessible name of the control ("View mode"). Not shown; put a visible Text label beside it when the meaning is not obvious from context. Lit, where an attribute can be absent, defaults the property to an empty string and warns in development when it is empty; React and React Native rely on the required type and do not warn, even for an empty string.

web, lit, rn, swiftui
options requiredarray { value: string; label: string; icon?: IconName | undefined; disabled?: boolean | undefined }[]—

Two to five options (guidance, not enforced: any count renders, with no warning). An empty array is a documented no-op: an empty group renders with no tab stop and no pill, keys are ignored, and nothing warns. Labels are one word; with `iconOnly` the label becomes the accessible name. The icon is an Icon whose `size` is the control's `size` (`sm` or `md`), on every platform, and whose colour is its segment's own — `segmentColor`, or `segmentSelectedColor` when selected — forwarded as the Icon's `overrides.color`, since there is no `currentColor` on native.

web, lit, rn, swiftui
valuestring—

Controlled selected value. Omit for uncontrolled.

web, lit, rn, swiftui
defaultValuestring—

Initially selected value. Defaults to the first enabled option — a segmented control always has a selection. A `value` or `defaultValue` is taken as given, never corrected: one naming a disabled option keeps that segment checked with the pill under it (arrows still skip it); one matching no option checks nothing and draws no pill (the pill is unmounted, and the next selection places it instantly rather than sliding it in). In both cases the tab stop is the first enabled segment, and arrows move from there when no segment has focus.

web, lit, rn, swiftui
iconOnlybooleanfalse

Show icons only (every option must have one); labels become accessible names and Tooltips, disabled segments included — an `aria-disabled` button still takes pointer events, so its label stays discoverable on hover. An option without `icon` warns in development once per instance (one message listing every option without an icon by its `value`, since that is the stable identifier; the wording itself is a development aid, not copy, so it is not contract) and that segment shows its label as text instead, at its natural width — no icon-sized box is reserved for it — so it never renders empty; that segment gets no Tooltip and no `aria-label`, since its visible text is its name.

web, lit, rn, swiftui
sizesm | mdmd

Toolbar (`sm`) or standard (`md`) height.

web, lit, rn, swiftui
fillbooleanfalse

Stretch to the container width with equal segments. Off, the group hugs its segments rather than filling the container (React Native: `alignSelf: 'flex-start'`, which is what keeps `fill` from being a no-op there).

web, lit, rn, swiftui

Events

1 row
EventTypeDescriptionPlatforms
onChangeonChange / change

Fired when the selection changes, with the new value. The change takes effect immediately.

web, lit, rn, swiftui

Accessibility

Decisions this component leaves to you

Supply an accessible name. This component has no text of its own to fall back on, so without one a screen reader announces its role and nothing else.

Implicit ARIA role: radiogroup.

Anatomy

groupsegmentsegmentLabelsegmentIcontooltipindicator

What generation guarantees

Accessible name — Every instance carries a programmatic name. Where the component has no text of its own to use, the name is a prop the consumer supplies.

Selected state — Selection is exposed programmatically (`aria-selected` / `aria-checked`).

Arrow navigation — Arrow keys move between items; Home and End jump to the ends.

Roving tabindex — The group is a single tab stop; the arrow keys move within it.

Keyboard operable — Everything the pointer can do here, the keyboard can do — see the keyboard section.

Visible focus — Keyboard focus is always drawn, from the theme’s focus tokens, and is never removed.

Contrast — Text and meaningful non-text contrast meets WCAG AA; the pairs are listed below.

Target size — Interactive targets are at least 24×24 CSS pixels (WCAG 2.2 Target Size, minimum).

Reduced motion — Under `prefers-reduced-motion: reduce` the transitions are dropped rather than shortened, and the component still reaches every state.

SegmentedControl keyboard support

6 rows
KeyAction
ArrowRight or ArrowDownMoves to and selects the next enabled segment, wrapping.
ArrowLeft or ArrowUpMoves to and selects the previous enabled segment, wrapping.
ArrowRightFrom the last segment wraps to the first.
HomeMoves to and selects the first enabled segment — this control always has a selection, so Home and End select as the arrows do.
EndMoves to and selects the last enabled segment.
Space or EnterSelects the focused segment. Each segment is a native button (web and Lit), so this is its own activation — the component adds no handler and nothing else is bound to these keys.

Contrast pairs

2 rows
ForegroundBackgroundLevel
color.foreground.mutedcolor.background.strongAA
color.foreground.strongcolor.backgroundAA

Under `prefers-reduced-motion: reduce` the transitions are dropped rather than shortened, and the component still reaches every state.