Skip to main content

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 for Tabs when 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"), bypassing ui/toggle-group — its default toggleVariants visual conflicts with Segmented, and reusing it would first require adding unstyledVisual support 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.

Result
Loading...
Live Editor
<Segmented options={['Day', 'Week', 'Month']} defaultValue="Week" />

Content Styles

3 content shapes, matching the Figma Style variant axis.

Text Only

Result
Loading...
Live Editor
<Segmented options={['List', 'Grid', 'Table']} defaultValue="List" />

Icon + Text

Result
Loading...
Live Editor
<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.

Result
Loading...
Live Editor
<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

Result
Loading...
Live Editor
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.

Result
Loading...
Live Editor
<div style={{ maxWidth: 360 }}>
  <Segmented options={['Overview', 'Analytics', 'Settings']} defaultValue="Overview" block />
</div>

Disabled

Disable the whole control, or a single option.

Root Disabled

Result
Loading...
Live Editor
<Segmented options={['List', 'Grid', 'Table']} defaultValue="List" disabled />

Item Disabled

Result
Loading...
Live Editor
<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

StateBackgroundText colorFont weight
Unselected / DefaultLabels/Secondary#3D3D3D400
Unselected / HoverGrays/Gray-2#D6D6D6Labels/Secondary#3D3D3D400
Selected (Default & Hover are identical)Foregrounds/White#FFFFFFForegrounds/Black#000000600
Disabled / Unselected (not covered by Figma, implementation-only)Labels/Disabled#A3A3A3400
Disabled / Selected (not covered by Figma, implementation-only)Foregrounds/White#FFFFFFLabels/Disabled#A3A3A3600

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

PropertyValueNote
Container padding2pxHardcoded fallback — no matching token in design-tokens
Container radius7pxHardcoded fallback — design-tokens only has Radius_5 / Radius_12 / Radius_20 / Rounded, none is 7px
Container gapSpacing_4
Item radiusRadius_5
Item padding-y2px
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 height13px / 18px (Font-Size-Footnote / Line-Height-Footnote)
Icon size20×20pxcolor 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

PropTypeDefaultDescription
optionsreadonly (string | SegmentedOption)[]-Option list; a bare string is shorthand for { value: s, label: s }
valuestring-Controlled selected value
defaultValuestring-Uncontrolled default value; falls back to the first option
blockbooleanfalseStretch to fill the parent width, splitting options evenly
disabledbooleanfalseDisables the whole control
onChange(value: string) => void-Fires on selection change; never fires with an empty value
aria-labelstring-Accessible name when there is no visible title
classNamestring-Custom root class name
itemClassNamestring-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' }.

FieldTypeDefaultDescription
valuestring-Unique value identifying the option
labelReactNode-Text content; when omitted, icon and aria-label become required
iconReactNode-Icon content; required together with aria-label when there is no label
disabledbooleanfalseDisables this option
classNamestring-Custom class name for this option
aria-labelstring-Accessible name; required when there is no label (icon-only options)

Differences from antd

  • size (size variants), shape (shape variants), and vertical (vertical layout) are not implemented — Figma has no corresponding variant for any of them yet.