Skip to content
Primitiv home
Framework
Consumption mode

Segmented Control

stableSource Figma

A single-select segmented control — a linear strip of segments where exactly one is selected. The selected segment is brand-filled; the rest are secondary.

Playground

Density

Preview

Size
Justify
import { SegmentedControl, SegmentedControlItem } from "@/components/ui/segmented-control";
<SegmentedControl size="md" justify="justified" defaultValue="grid" aria-label="View">  <SegmentedControlItem value="list">List</SegmentedControlItem>  <SegmentedControlItem value="grid">Grid</SegmentedControlItem>  <SegmentedControlItem value="board">Board</SegmentedControlItem></SegmentedControl>

Density is set by a data-density ancestor — the Context system, not a SegmentedControl prop.

Installation

npx primitiv add segmented-control

Import

import { SegmentedControl } from "@/components/ui/segmented-control";

Copied into your project as .primitiv-segmented-control — you own the file afterwards, so upgrades are opt-in.

Headless mode installs the npm package instead: @primitiv-ui/react

Anatomy

<SegmentedControl defaultValue="grid" aria-label="View">  <SegmentedControlItem value="list">List</SegmentedControlItem>  <SegmentedControlItem value="grid">Grid</SegmentedControlItem>  <SegmentedControlItem value="board">Board</SegmentedControlItem></SegmentedControl>

Props

SegmentedControl.Root

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessWhen true, Root delegates rendering to a single consumer-supplied element via the Slot pattern instead of the default <div>; role="radiogroup", aria-orientation, data-orientation, data-disabled, dir, and remaining props are merged onto it.
childrenReactNodeheadlessThe control's segments — SegmentedControlItemProps`SegmentedControl.Item` elements.
defaultValuestringheadlessValue of the segment selected on first render. Omit for nothing selected on mount. Forbidden in controlled mode — use value instead.
dir"ltr" | "rtl"headlessReading direction. In "rtl" the horizontal arrow pair is swapped so Arrow Left moves forward. When omitted, it is inherited from the nearest DirectionProvider, falling back to "ltr".
disabledbooleanfalseheadlessDisables the entire control — every segment is non-interactive and excluded from arrow-key navigation, and data-disabled="" is set on the root. Individual segments can also be disabled via the Item's own disabled prop.
onValueChange(value: string) => voidheadlessCalled with the new value whenever the selection changes. Optional in uncontrolled mode. Called with the new value whenever the selection changes. Required in controlled mode.
orientation"horizontal" | "vertical""horizontal"headlessWhich arrow-key pair moves focus and selection through the segments, and the visual axis. "horizontal" binds Arrow Left/Right; "vertical" binds Arrow Up/Down. Reflected as aria-orientation and data-orientation on the root.
refRef<HTMLDivElement>headlessAllows getting a ref to the component instance. Once the component unmounts, React will set ref.current to null (or call the ref with null if you passed a callback ref). Forwarded to the underlying HTMLDivElement.
valuestringheadlessForbidden in uncontrolled mode — use defaultValue instead. The currently selected value. Must be kept in sync by the parent via onValueChange.
size"xs" | "sm" | "md" | "lg" | "xl"mdstyledControl size for the whole control; data-density scales each size further.
justify"content" | "justified"justifiedstyledWhether segments share the track width equally or size to their content.

SegmentedControl.Item

Extends HTMLButtonElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
value (required)stringheadlessUniquely identifies this segment within the control and is reported to onValueChange / matched against Root's value when selected. Required.
asChildbooleanfalseheadlessWhen true, renders the single child element via the Slot pattern instead of the default <button>; ARIA, data-state, data-disabled, tabIndex, onClick, onKeyDown, disabled, and ref are merged onto it. A non-focusable child must be made focusable by the consumer.
childrenReactNodeheadlessThe segment's visible content — typically its text label.
disabledbooleanfalseheadlessDisables just this segment: forwards the native disabled attribute, removes it from arrow-key navigation and the roving-tabindex home base, and sets data-disabled="". The whole control can also be disabled via Root's disabled prop.
refRef<HTMLButtonElement>headlessAllows getting a ref to the component instance. Once the component unmounts, React will set ref.current to null (or call the ref with null if you passed a callback ref). Forwarded to the underlying HTMLButtonElement.

Styling contract

Control frame

--primitiv-segmented-control-track-bg--primitiv-segmented-control-track-border-color--primitiv-segmented-control-track-border-width--primitiv-segmented-control-track-padding--primitiv-segmented-control-track-gap--primitiv-segmented-control-radius

Item

--primitiv-segmented-control-item-radius--primitiv-segmented-control-item-height--primitiv-segmented-control-item-padding-inline--primitiv-segmented-control-item-gap--primitiv-segmented-control-item-icon-size--primitiv-segmented-control-item-border-width--primitiv-segmented-control-item-bg--primitiv-segmented-control-item-fg--primitiv-segmented-control-item-border-color--primitiv-segmented-control-item-bg-selected--primitiv-segmented-control-item-fg-selected--primitiv-segmented-control-item-border-color-selected--primitiv-segmented-control-item-font-family--primitiv-segmented-control-item-font-size--primitiv-segmented-control-item-font-weight--primitiv-segmented-control-item-line-height

Keyboard

KeyBehaviour
TabMove into or out of the control in one keystroke, landing on the selected segment.
ArrowRight / ArrowLeftMove focus and selection to the next / previous segment, wrapping at the ends and skipping disabled ones. Horizontal orientation.
ArrowDown / ArrowUpThe same, when orientation="vertical".
Space / EnterSelect the focused segment — native <button> activation, so it also works if focus arrived by other means.

Data attributes

SegmentedControl

className: .primitiv-segmented-control

AttributeValueWhen
data-orientationhorizontal | verticalhorizontal / vertical
data-disabled""the whole control is disabled

SegmentedControlItem

className: .primitiv-segmented-control__item

AttributeValueWhen
data-statechecked | uncheckedselected / not selected
data-disabled""disabled

Accessibility

  • This is role="radiogroup" with role="radio" segments and aria-checked — the same semantics as RadioGroup, drawn as a strip. So it announces as a set of exclusive options, which is only honest if one of them really is always active.
  • Not ToggleGroup. That one is role="group" with aria-pressed and can be deselected to nothing. Choosing by appearance rather than meaning gives assistive technology the wrong model of what the control does, and no visual review catches it.
  • Label the group. aria-label on the Root works — it is the radiogroup, unlike Slider's role-less root — and a visible label needs an id with aria-labelledby pointing at it. Not a Field.Label: that renders a <label htmlFor> aimed at an id no segment claims, because only Input, Textarea and Select read FieldContext. Without a name, a screen-reader user hears three options and no question.
  • Arrow keys move selection, not just focus. That is correct for a radio group, but it means the value changes as someone navigates — so anything expensive downstream should react to the settled value rather than firing per keystroke.
  • A disabled Item stays visible and announced but is skipped by the arrows and takes no tab stop, which is the right treatment for an option that exists and is temporarily unavailable. If it will never be available, leave it out instead.
  • disabled on the Root makes the whole control inert while still reporting the current selection, so it reads correctly rather than announcing as empty.

Examples

SegmentedControl or ToggleGroup?

They look almost identical and mean different things, so pick by semantics, not appearance. Use SegmentedControl when the choice is a value and one option is always active — a view mode, a density, a plan. Use ToggleGroup when the buttons are commands or toggles that can each be on or off, including “none selected”. Under the hood: this is role="radiogroup" / role="radio" with aria-checked and no way to deselect; ToggleGroup is role="group" with aria-pressed and can end up empty. Try clicking the selected segment below — nothing happens, because a value cannot be un-chosen.

Density
import { SegmentedControl, SegmentedControlItem } from "@/components/ui/segmented-control";
// one of these is always active<SegmentedControl defaultValue="grid" aria-label="View">  <SegmentedControlItem value="list">List</SegmentedControlItem>  <SegmentedControlItem value="grid">Grid</SegmentedControlItem>  <SegmentedControlItem value="board">Board</SegmentedControlItem></SegmentedControl>

Controlled

Pass value and onValueChange together and the parent owns the selection — the usual reason being that the value drives something else on the page. onValueChange hands you the segment's value string, so there is no event to unpack.

Density

Selected: grid

import { useState } from "react";import { SegmentedControl, SegmentedControlItem } from "@/components/ui/segmented-control";
const [view, setView] = useState("grid");
<SegmentedControl value={view} onValueChange={setView} aria-label="View">  <SegmentedControlItem value="list">List</SegmentedControlItem>  <SegmentedControlItem value="grid">Grid</SegmentedControlItem>  <SegmentedControlItem value="board">Board</SegmentedControlItem></SegmentedControl>

Disabling a segment, or all of them

disabled on an Item takes that one segment out of play: it stops responding, leaves the roving tab order, and the arrow keys skip over it — which is what makes it right for an option that exists but is not available yet (this site's own header does exactly that for Vue and Svelte). disabled on the Root disables every segment and the whole control takes no tab stop, while still reporting which one is selected so it reads correctly.

Density
import { SegmentedControl, SegmentedControlItem } from "@/components/ui/segmented-control";
// one unavailable option<SegmentedControl defaultValue="react" aria-label="Framework">  <SegmentedControlItem value="react">React</SegmentedControlItem>  <SegmentedControlItem value="vue" disabled>Vue</SegmentedControlItem>  <SegmentedControlItem value="svelte" disabled>Svelte</SegmentedControlItem></SegmentedControl>
// the whole control inert<SegmentedControl disabled defaultValue="grid" aria-label="View">  <SegmentedControlItem value="list">List</SegmentedControlItem>  <SegmentedControlItem value="grid">Grid</SegmentedControlItem>  <SegmentedControlItem value="board">Board</SegmentedControlItem></SegmentedControl>

Sizing the strip (justify)

justify decides what sets the width. justified (the default) splits the strip evenly, so every segment is the same width regardless of label length — right for a control that fills a column or sits in a toolbar of fixed width. content sizes each segment to its own label, so the strip hugs its contents. The difference only shows when the labels are uneven, which is why this example uses one long one.

Density
import { SegmentedControl, SegmentedControlItem } from "@/components/ui/segmented-control";
<SegmentedControl justify="justified" defaultValue="day" aria-label="Period">  <SegmentedControlItem value="day">Day</SegmentedControlItem>  <SegmentedControlItem value="week">Week</SegmentedControlItem>  <SegmentedControlItem value="quarter">This quarter</SegmentedControlItem></SegmentedControl><SegmentedControl justify="content" defaultValue="day" aria-label="Period">  <SegmentedControlItem value="day">Day</SegmentedControlItem>  <SegmentedControlItem value="week">Week</SegmentedControlItem>  <SegmentedControlItem value="quarter">This quarter</SegmentedControlItem></SegmentedControl>

Vertical

orientation="vertical" stacks the segments and moves the arrow keys with them — ArrowDown / ArrowUp navigate, and aria-orientation tells assistive technology which axis it is on. Worth knowing that the keys follow the orientation rather than being fixed, so you do not have to document a different keymap for a vertical strip.

Density
import { SegmentedControl, SegmentedControlItem } from "@/components/ui/segmented-control";
<SegmentedControl orientation="vertical" defaultValue="grid" aria-label="View">  <SegmentedControlItem value="list">List</SegmentedControlItem>  <SegmentedControlItem value="grid">Grid</SegmentedControlItem>  <SegmentedControlItem value="board">Board</SegmentedControlItem></SegmentedControl>

Labelling

The group needs a name — each segment labels itself, but without one the options are announced with no idea what they choose between. aria-label on the Root is the quick path and, unlike Slider, it works: the Root is the role="radiogroup". For a visible label, give it an id and point aria-labelledby at it. Not a Field.Label: that renders a <label htmlFor> aimed at an id no segment claims — only Input, Textarea and Select read FieldContext — so it would associate with nothing.

Density
Density
import { SegmentedControl, SegmentedControlItem } from "@/components/ui/segmented-control";
// visible label, referenced by the group<span id="density-label">Density</span><SegmentedControl aria-labelledby="density-label" defaultValue="comfortable">  <SegmentedControlItem value="compact">Compact</SegmentedControlItem>  <SegmentedControlItem value="comfortable">Comfortable</SegmentedControlItem></SegmentedControl>
// no visible label? name the group directly<SegmentedControl aria-label="Density">