A set of independent on/off buttons in a shared track — any number can be pressed, including none. Framed like a Button: a pressed item is action/primary, an unpressed one action/secondary, inside a transparent bordered track whose radius is concentric with the items.
Copied into your project as .primitiv-toggle-group — you own the file afterwards, so upgrades are opt-in.
Headless mode installs the npm package instead: @primitiv-ui/react
Anatomy
Two parts: the Root is the role="group" that owns the pressed value and the roving tabindex, and each Item is a <button> carrying aria-pressed and data-state="on" | "off". Both surfaces have the same shape — the copied file adds classes and nothing else. The track is a transparent bordered frame, so the control sits on any surface; its radius is concentric with the items, calc(item-radius + track-inset).
Generated from source — headless rows from each *Props type’s JSDoc, styled rows from contract.json. Never hand-maintained.
ToggleGroup.Root
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
From
Description
type* (required)
"single" | "multiple"
—
headless
Selects single-selection semantics: at most one item pressed at a time,
and pressing the active item again clears the selection.
Selects multiple-selection semantics: any number of items can be pressed
simultaneously and each toggles independently.
asChild
boolean
false
headless
Render a single consumer-supplied element in place of the native
<div>, with the group's role="group", data-orientation, and ref
merged onto it via the Slot pattern.
children
ReactNode
—
headless
The ToggleGroupItemProps`ToggleGroup.Item` elements that make
up the segmented control.
defaultValue
string | string[]
—
headless
Value of the item pressed on first render. Omit to start with nothing
pressed.
Forbidden in controlled mode — use value.
Values of the items pressed on first render. Omit to start with nothing
pressed.
dir
"ltr" | "rtl"
—
headless
Reading direction. In "rtl" the horizontal arrow keys are mirrored so
focus follows the visual order. Inherited from the nearest
DirectionProvider when omitted, falling back to "ltr".
Forbidden in uncontrolled mode.
Called with the requested next value — the pressed item's value, or
undefined when the active item is pressed again to deselect it.
Called with the complete next array of pressed values whenever the user
toggles any item.
orientation
"horizontal" | "vertical"
"horizontal"
headless
Layout axis for keyboard navigation. "horizontal" binds
ArrowLeft/ArrowRight; "vertical" binds ArrowUp/ArrowDown. Surfaces as
data-orientation on the root for styling; it does not itself apply any
flex/grid layout.
ref
Ref<HTMLDivElement>
—
headless
Forwarded to the underlying HTMLDivElement.
value
string | string[]
—
headless
Forbidden in uncontrolled mode — use defaultValue.
The currently pressed item's value, or undefined when none is pressed.
Must be kept in sync by the caller via onValueChange.
The full set of currently pressed item values. Must be kept in sync by
the caller via onValueChange.
size
"xs" | "sm" | "md" | "lg" | "xl"
md
styled
Control size for the whole widget; data-density scales each size further.
justify
"content" | "justified"
content
styled
Whether items size to their content or share the track width equally.
ToggleGroup.Item
Extends HTMLButtonElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
From
Description
value* (required)
string
—
headless
Identifies this item within the group. It is this string — not the
visible label — that is compared against the group's pressed
value / defaultValue; use children for the label.
asChild
boolean
false
headless
Render a single consumer-supplied element in place of the native
<button>, with the item's aria-pressed, data-state, tabIndex, and
event handlers merged onto it via the Slot pattern.
disabled
boolean
false
headless
Forwards the native disabled attribute and removes the item from the
roving tab order so arrow-key navigation skips it. Also sets
data-disabled="" for CSS targeting.
ref
Ref<HTMLButtonElement>
—
headless
Forwarded to the underlying HTMLButtonElement.
Styling contract
21 CSS custom properties on .primitiv-toggle-group — mode-agnostic. These names are the stable surface; the values are not.
One tab stop for the whole group — a roving tabindex, so a five-item toolbar costs one Tab to pass, not five. Unlike SegmentedControl, the arrows move focus without pressing anything: pressing is a separate, explicit act, which is what independent toggles require.
Key
Behaviour
Tab
Move into or out of the group in one keystroke.
ArrowRight / ArrowLeft
Move focus to the next / previous item, skipping disabled ones. Focus only — nothing is toggled. Mirrored under dir="rtl".
ArrowDown / ArrowUp
The same, when orientation="vertical".
Space / Enter
Toggle the focused item — native <button> activation. Under type="single" this also clears the item if it was already on.
Data attributes
Emitted automatically by the headless primitive — style against these rather than adding your own state classes. Grouped by the part that emits them, since most are emitted by more than one.
ToggleGroup
className:.primitiv-toggle-group
Attribute
Value
When
data-orientation
horizontal | vertical
horizontal / vertical
ToggleGroup.Root
Attribute
Value
When
data-orientation
horizontal | vertical
horizontal / vertical
ToggleGroupItem
className:.primitiv-toggle-group__item
Attribute
Value
When
data-state
on | off
pressed / not pressed
data-disabled
""
disabled
ToggleGroup.Item
Attribute
Value
When
data-state
on | off
pressed / not pressed
data-disabled
""
disabled
Accessibility
This is role="group" with aria-pressed on each button — a set of independent toggles. It is notSegmentedControl (role="radiogroup" / aria-checked), even though the two are now visually identical. Choosing by appearance gives assistive technology the wrong model of the control, and no visual review catches it.
Name the group.aria-label on the Root, or aria-labelledby pointing at a visible heading. Without one, a screen-reader user hears a set of buttons with no idea what they belong to.
Icon-only items each need their own name. The formatting toolbar is the canonical use and the worst case for this: three buttons whose only content is a glyph announce as nothing at all without aria-label. Every icon example on this page carries one.
The arrows move focus without toggling, which is correct here and the opposite of SegmentedControl. It means a keyboard user can traverse the toolbar without changing anything — essential when each item is a real command.
type="single" is clearable: pressing the active item empties the value. That is a genuine state your handler must accept — and the callback hands you undefined rather than an empty string — and it is the behaviour that makes this component the right choice over a radio group.
A disabled item stays visible and announced but leaves the tab order and is skipped by the arrows. Right for a command unavailable in the current context; if it will never be available, leave it out.
Examples
Every example below reacts to the density control. Currently showing Styled mode.
ToggleGroup or SegmentedControl?
These two look identical, and that is on purpose — since the 2026-08-26 redesign both use the framed-control anatomy and the same action/* fills. So you cannot pick by appearance; pick by what the control means. Use ToggleGroup when each button is an independent toggle or command: any number can be on, including none. Use SegmentedControl when the choice is a single value that is always set. Underneath: role="group" / aria-pressed and clearable, versus role="radiogroup" / aria-checked and never empty. Press the pressed item in each — the ToggleGroup clears, the SegmentedControl does not.
import{ToggleGroup,ToggleGroupItem}from"@/components/ui/toggle-group";import{SegmentedControl,SegmentedControlItem}from"@/components/ui/segmented-control";// independent toggles — can end up empty<ToggleGrouptype="multiple"aria-label="Formatting"><ToggleGroupItemvalue="bold">Bold</ToggleGroupItem></ToggleGroup>// one value, always set<SegmentedControldefaultValue="grid"aria-label="View"><SegmentedControlItemvalue="grid">Grid</SegmentedControlItem></SegmentedControl>
import{ToggleGroup}from"@primitiv-ui/react";import{SegmentedControl}from"@primitiv-ui/react";// independent toggles — can end up empty<ToggleGroup.Roottype="multiple"aria-label="Formatting"><ToggleGroup.Itemvalue="bold">Bold</ToggleGroup.Item></ToggleGroup.Root>// one value, always set<SegmentedControl.RootdefaultValue="grid"aria-label="View"><SegmentedControl.Itemvalue="grid">Grid</SegmentedControl.Item></SegmentedControl.Root>
Multi-select (type="multiple")
type="multiple" makes the value a string[], and any number of items can be on at once — the formatting toolbar this component exists for. Note what a multi-select strip looks like with two pressed: two brand-filled buttons side by side. That was a known cost of matching SegmentedControl's fill language, and it is the one place the shared look reads least well.
type="single" allows at most one, and the value is a plain string — but pressing the active item clears it — and the callback hands you undefined, not an empty string, so type your state string | undefined. That clearability is the whole difference from SegmentedControl, which cannot be emptied. Press Grid twice to see it.
justify="content" (the default) sizes each item to its own content, so the whole control hugs — right for an icon toolbar, where equal widths would stretch the glyphs apart. justified shares the track's width equally between the items, which suits word labels of uneven length. The two rows below are drawn in different containers on purpose, because that is the difference: the first sizes itself, the second fills the column it is given. justified in a container that hugs has nothing to distribute and comes out identical to content.
Five sizes, each rescaling again with the nearest data-density ancestor. The track's corner follows the items': its radius is calc(item-radius + track-inset), so the outer curve stays parallel to the inner one at every size instead of needing a value per size.
disabled on an Item forwards the native attribute and removes it from the roving tab order, so the arrow keys skip it — right for a command that is unavailable in the current context. The item keeps reporting its pressed state, so a disabled-but-on toggle still reads correctly.