Segmented
- Component overview: A mutually-exclusive value picker for switching between a small set of options (e.g. view modes, filter dimensions). Unlike
Tabs, it does not bind to content panels — reach forTabswhen switching also needs to swap the visible content. - Interaction feature: Exactly one option is always selected; clicking the already-selected option is a no-op and does not fire
onChange. Supports both controlled and uncontrolled usage. - Implementation note: Built directly on
@radix-ui/react-toggle-group(type="single"), bypassingui/toggle-group— its defaulttoggleVariantsvisual conflicts with Segmented, and reusing it would first require addingunstyledVisualsupport to the toggle family, which is out of scope for this iteration. - Figma spec
Basic Usage
Uncontrolled — when defaultValue is omitted, the first option is selected by default.
<Segmented options={['Day', 'Week', 'Month']} defaultValue="Week" />
Content Styles
3 content shapes, matching the Figma Style variant axis.
Text Only
<Segmented options={['List', 'Grid', 'Table']} defaultValue="List" />
Icon + Text
<Segmented options={[ { value: 'search', label: 'Search', icon: <Search size={16} /> }, { value: 'add', label: 'Add', icon: <Plus size={16} /> }, ]} defaultValue="search" />
Icon Only
Pure-icon options (no label) must provide icon and aria-label — this is enforced at the type level so an icon-only button never ends up without an accessible name.
<Segmented aria-label="Icon only demo" options={[ { value: 'search', icon: <Search size={16} />, 'aria-label': 'Search' }, { value: 'add', icon: <Plus size={16} />, 'aria-label': 'Add' }, ]} defaultValue="search" />
Controlled Usage
const ControlledSegmented = () => { const [value, setValue] = useState('day') return ( <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> <Segmented options={[ { value: 'day', label: 'Day' }, { value: 'week', label: 'Week' }, { value: 'month', label: 'Month' }, ]} value={value} onChange={setValue} /> <span style={{ fontSize: 12, color: '#999' }}>Selected: {value}</span> </div> ) } render(<ControlledSegmented />)
Block
block stretches the root to fill its parent's width, splitting options evenly.
<div style={{ maxWidth: 360 }}> <Segmented options={['Overview', 'Analytics', 'Settings']} defaultValue="Overview" block /> </div>
Disabled
Disable the whole control, or a single option.
Root Disabled
<Segmented options={['List', 'Grid', 'Table']} defaultValue="List" disabled />
Item Disabled
<Segmented options={[ { value: 'list', label: 'List' }, { value: 'grid', label: 'Grid', disabled: true }, { value: 'table', label: 'Table' }, ]} defaultValue="list" />
Accessibility
The root renders role="radiogroup" and each option renders role="radio" + aria-checked, matching the W3C recommended ARIA pattern for a segmented control (Radix's ToggleGroup defaults the root to role="group", which pairs incorrectly with the radio role Radix assigns to items in type="single" mode — Segmented overrides it explicitly). Icon-only options require aria-label at the type level so every option always has an accessible name.
Design Tokens
Colors
| State | Background | Text color | Font weight |
|---|---|---|---|
| Unselected / Default | — | Labels/Secondary#3D3D3D | 400 |
| Unselected / Hover | Grays/Gray-2#D6D6D6 | Labels/Secondary#3D3D3D | 400 |
| Selected (Default & Hover are identical) | Foregrounds/White#FFFFFF | Foregrounds/Black#000000 | 600 |
| Disabled / Unselected (not covered by Figma, implementation-only) | — | Labels/Disabled#A3A3A3 | 400 |
| Disabled / Selected (not covered by Figma, implementation-only) | Foregrounds/White#FFFFFF | Labels/Disabled#A3A3A3 | 600 |
Disabled + selected keeps the selected pill's white background and shadow, but the text color downgrades to Labels/Disabled — matching antd Segmented's disabled look ("this is the current selection, but it can't be changed") rather than staying pure black.
Selected uses Foregrounds/White and Foregrounds/Black rather than Backgrounds/Primary / Labels/Primary: the Foregrounds series stays pure white / pure black in both light and dark mode, matching the design intent that the selected pill never flips with the theme. The selected state also carries a 0 0 32px var(--Effects-Shadow-Default) shadow.
The unselected text color uses Labels/Secondary (#3D3D3D light / #D6D6D6 dark) rather than the visually lighter Labels/Tertiary (#6B6B6B), because Tertiary measures only 4.47:1 against the container background (Grays/Gray-1, #EBEBEB) and 3.67:1 against the hover background (Grays/Gray-2, #D6D6D6) — both below the WCAG 2.1 AA text contrast requirement of 4.5:1. Labels/Secondary measures 9.11:1 (light) / 8.69:1 (dark) unselected default, and 7.47:1 (both themes) unselected hover — all passing AA in both themes.
Geometry
| Property | Value | Note |
|---|---|---|
| Container padding | 2px | Hardcoded fallback — no matching token in design-tokens |
| Container radius | 7px | Hardcoded fallback — design-tokens only has Radius_5 / Radius_12 / Radius_20 / Rounded, none is 7px |
| Container gap | Spacing_4 | |
| Item radius | Radius_5 | |
| Item padding-y | 2px | |
| Item padding-x (text only) | Spacing_12 on both sides | |
| Item padding-x (icon + text) | Spacing_8 left / Spacing_12 right | |
| Item padding-x (icon only) | Spacing_8 on both sides | |
| Font size / line height | 13px / 18px (Font-Size-Footnote / Line-Height-Footnote) | |
| Icon size | 20×20px | color inherits currentColor; the wrapper is internally centered (inline-flex items-center justify-center) so icons smaller than 20×20px stay vertically centered with the item and adjacent text |
Item variants (Selected × Hover × Style) live in the Figma component set node 26805:24, in the same file (5ssRkvUdqpsRwwW59ooQCp) as the container node above.
Props
Segmented
| Prop | Type | Default | Description |
|---|---|---|---|
options | readonly (string | SegmentedOption)[] | - | Option list; a bare string is shorthand for { value: s, label: s } |
value | string | - | Controlled selected value |
defaultValue | string | - | Uncontrolled default value; falls back to the first option |
block | boolean | false | Stretch to fill the parent width, splitting options evenly |
disabled | boolean | false | Disables the whole control |
onChange | (value: string) => void | - | Fires on selection change; never fires with an empty value |
aria-label | string | - | Accessible name when there is no visible title |
className | string | - | Custom root class name |
itemClassName | string | - | Class name applied to every item |
Any other div props (id, data-*, onKeyDown, etc.) are passed through to the root element.
SegmentedOption
A bare string (e.g. 'a') is shorthand for { value: 'a', label: 'a' }.
| Field | Type | Default | Description |
|---|---|---|---|
value | string | - | Unique value identifying the option |
label | ReactNode | - | Text content; when omitted, icon and aria-label become required |
icon | ReactNode | - | Icon content; required together with aria-label when there is no label |
disabled | boolean | false | Disables this option |
className | string | - | Custom class name for this option |
aria-label | string | - | Accessible name; required when there is no label (icon-only options) |
Differences from antd
size(size variants),shape(shape variants), andvertical(vertical layout) are not implemented — Figma has no corresponding variant for any of them yet.