VisorVisor
ComponentsForm

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-control

This 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 component
  • components/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

PropTypeDefaultDescription
options*{ value: string; label: ReactNode; icon?: ReactNode; disabled?: boolean }[]—The segments, in order. Each takes an optional icon and a disabled flag.
valuestring—Controlled value. One option is always on.
defaultValuestring—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.
fullWidthbooleanfalseEqual-width segments spanning the container (the list-filter treatment).
disabledbooleanfalseDisables 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 has role="group" and each segment role="radio" with aria-checked
  • Give the control an aria-label that 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: reduce is set
  • Each label reserves its width at the active and inactive weight with aria-hidden copies, so selecting a segment shifts nothing, even with a heavier active-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.