Segmented Control
A single-select pill group with a sliding active indicator and an options API. A thin wrapper over ToggleGroup that never goes empty.
Default
Sizes
Full width
fullWidth makes the segments equal-width and spans the container, the list-filter treatment.
Disabled option
Installation
npx visor add segmented-controlThis copies two files into your project, and adds toggle-group (which it composes) if you do not have it:
components/ui/segmented-control/segmented-control.tsx— the componentcomponents/ui/segmented-control/segmented-control.module.css— the styles
Usage
import { SegmentedControl } from '@/components/ui/segmented-control/segmented-control';
const [status, setStatus] = React.useState('unpaid');
<SegmentedControl
aria-label="Payment status"
value={status}
onValueChange={setStatus}
options={[
{ value: 'unpaid', label: 'Unpaid' },
{ value: 'paid', label: 'Paid' },
{ value: 'any', label: 'Any' },
]}
/>Pass value and onValueChange to control it, or defaultValue to leave it uncontrolled. Each option also takes an icon and a disabled flag.
One option is always on
Clicking the active segment keeps it, and onValueChange is never called with an empty string, whether the control is controlled or not. With no value or defaultValue, the first enabled option starts on. For a filter that can be cleared, use a ToggleGroup or a Select instead.
API Reference
SegmentedControlProps
| Prop | Type | Default | Description |
|---|---|---|---|
options* | { value: string; label: ReactNode; icon?: ReactNode; disabled?: boolean }[] | — | The segments, in order. Each takes an optional icon and a disabled flag. |
value | string | — | Controlled value. One option is always on. |
defaultValue | string | — | Uncontrolled initial value. Falls back to the first enabled option. |
onValueChange | (value: string) => void | — | Fires with the new value. Never fires with an empty string: clicking the active segment keeps it. |
size | 'xs' | 'sm' | 'md' | 'lg' | 'md' | Segment height and type size, passed through to ToggleGroup. |
fullWidth | boolean | false | Equal-width segments spanning the container (the list-filter treatment). |
disabled | boolean | false | Disables every segment. |
Theming
The resting edge comes from the shared --control-edge-* tokens, so control.edge-width: 0 removes it without moving the layout. Focus stays visible. The well, the track radius, the sliding pill, its ink, the state edge on it and the inactive and active label type are bindable through the segmented-control family (--segmented-control-track-bg, -radius, -indicator-bg, -indicator-text, -indicator-edge, -inactive-text, -active-weight); see Component tokens.
The active pill carries a 1px inset state edge (--control-state-edge-width, colour --segmented-control-indicator-edge, default --text-primary) so the selected state keeps a 3:1 boundary of its own (WCAG 1.4.11). It sits inside the pill, so nothing shifts, and it stays when edges are off. Bind the colour to transparent where the fill already clears 3:1 against the well.
Accessibility
- Built on ToggleGroup (
@radix-ui/react-toggle-group,type="single"): the group hasrole="group"and each segmentrole="radio"witharia-checked - Give the control an
aria-labelthat names what it filters (for example "Payment status") - Keyboard: Tab enters the group, Arrow keys move between segments, Space or Enter selects the focused segment
- The active segment is told apart by fill and by
aria-checked, not by colour alone - The sliding indicator snaps, with no transition, when
prefers-reduced-motion: reduceis set - Each label reserves its width at the active and inactive weight with
aria-hiddencopies, so selecting a segment shifts nothing, even with a heavieractive-weight - Focus draws a ring outside the pill that stays visible when edges are off
Label Centering
Segment labels are centred optically in any font, inherited from ToggleGroup. See docs/label-centering.md.