Skip to content
Primitiv home
Framework
Consumption mode

Toggle Group

stableSource Figma

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.

Playground

Density

Preview

Size
Justify
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
<ToggleGroup type="multiple" size="md" justify="content" aria-label="Text formatting">  <ToggleGroupItem value="bold" aria-label="Bold">B</ToggleGroupItem>  <ToggleGroupItem value="italic" aria-label="Italic">I</ToggleGroupItem>  <ToggleGroupItem value="underline" aria-label="Underline">U</ToggleGroupItem></ToggleGroup>

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

Installation

npx primitiv add toggle-group

Import

import { ToggleGroup } from "@/components/ui/toggle-group";

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

<ToggleGroup type="multiple" aria-label="Text formatting">  <ToggleGroupItem value="bold" aria-label="Bold">B</ToggleGroupItem>  <ToggleGroupItem value="italic" aria-label="Italic">I</ToggleGroupItem>  <ToggleGroupItem value="underline" aria-label="Underline">U</ToggleGroupItem></ToggleGroup>

Props

ToggleGroup.Root

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

PropTypeDefaultFromDescription
type (required)"single" | "multiple"headlessSelects 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.
asChildbooleanfalseheadlessRender 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.
childrenReactNodeheadlessThe ToggleGroupItemProps`ToggleGroup.Item` elements that make up the segmented control.
defaultValuestring | string[]headlessValue 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"headlessReading 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".
onValueChange((value: string | undefined) => void) | ((value: string[]) => void)headlessForbidden 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"headlessLayout 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.
refRef<HTMLDivElement>headlessForwarded to the underlying HTMLDivElement.
valuestring | string[]headlessForbidden 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"mdstyledControl size for the whole widget; data-density scales each size further.
justify"content" | "justified"contentstyledWhether 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.

PropTypeDefaultFromDescription
value (required)stringheadlessIdentifies 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.
asChildbooleanfalseheadlessRender 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.
disabledbooleanfalseheadlessForwards 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.
refRef<HTMLButtonElement>headlessForwarded to the underlying HTMLButtonElement.

Styling contract

Control frame

--primitiv-toggle-group-track-inset--primitiv-toggle-group-radius--primitiv-toggle-group-track-bg--primitiv-toggle-group-track-border-color--primitiv-toggle-group-track-border-width

Item

--primitiv-toggle-group-item-height--primitiv-toggle-group-item-padding-inline--primitiv-toggle-group-item-gap--primitiv-toggle-group-item-icon-size--primitiv-toggle-group-item-border-width--primitiv-toggle-group-item-radius--primitiv-toggle-group-item-bg--primitiv-toggle-group-item-fg--primitiv-toggle-group-item-border-color--primitiv-toggle-group-item-bg-on--primitiv-toggle-group-item-fg-on--primitiv-toggle-group-item-border-color-on--primitiv-toggle-group-item-font-family--primitiv-toggle-group-item-font-size--primitiv-toggle-group-item-font-weight--primitiv-toggle-group-item-line-height

Keyboard

KeyBehaviour
TabMove into or out of the group in one keystroke.
ArrowRight / ArrowLeftMove focus to the next / previous item, skipping disabled ones. Focus only — nothing is toggled. Mirrored under dir="rtl".
ArrowDown / ArrowUpThe same, when orientation="vertical".
Space / EnterToggle the focused item — native <button> activation. Under type="single" this also clears the item if it was already on.

Data attributes

ToggleGroup

className: .primitiv-toggle-group

AttributeValueWhen
data-orientationhorizontal | verticalhorizontal / vertical

ToggleGroupItem

className: .primitiv-toggle-group__item

AttributeValueWhen
data-stateon | offpressed / not pressed
data-disabled""disabled

Accessibility

  • This is role="group" with aria-pressed on each button — a set of independent toggles. It is not SegmentedControl (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

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.

Density
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";import { SegmentedControl, SegmentedControlItem } from "@/components/ui/segmented-control";
// independent toggles — can end up empty<ToggleGroup type="multiple" aria-label="Formatting">  <ToggleGroupItem value="bold">Bold</ToggleGroupItem></ToggleGroup>
// one value, always set<SegmentedControl defaultValue="grid" aria-label="View">  <SegmentedControlItem value="grid">Grid</SegmentedControlItem></SegmentedControl>

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.

Density

Pressed: ["bold"]

import { useState } from "react";import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
const [value, setValue] = useState<string[]>(["bold"]);
<ToggleGroup type="multiple" value={value} onValueChange={setValue} aria-label="Text formatting">  <ToggleGroupItem value="bold" aria-label="Bold">B</ToggleGroupItem>  <ToggleGroupItem value="italic" aria-label="Italic">I</ToggleGroupItem>  <ToggleGroupItem value="underline" aria-label="Underline">U</ToggleGroupItem></ToggleGroup>

Single with clear (type="single")

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.

Density

Value: "grid"

import { useState } from "react";import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
const [value, setValue] = useState<string | undefined>("grid");
<ToggleGroup type="single" value={value} onValueChange={setValue} aria-label="View">  <ToggleGroupItem value="list">List</ToggleGroupItem>  <ToggleGroupItem value="grid">Grid</ToggleGroupItem>  <ToggleGroupItem value="board">Board</ToggleGroupItem></ToggleGroup>

Sizing the track (justify)

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.

Density

content — the control sizes itself to its items

justified — the items share the container equally

import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
<ToggleGroup type="single" justify="content" aria-label="Period">  <ToggleGroupItem value="day">Day</ToggleGroupItem>  <ToggleGroupItem value="quarter">Quarter</ToggleGroupItem></ToggleGroup><ToggleGroup type="single" justify="justified" aria-label="Period">  <ToggleGroupItem value="day">Day</ToggleGroupItem>  <ToggleGroupItem value="quarter">Quarter</ToggleGroupItem></ToggleGroup>

Sizes and density

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.

Density
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
<div data-density="comfortable">  <ToggleGroup type="multiple" size="xs" aria-label="Text formatting">    <ToggleGroupItem value="bold" aria-label="Bold">B</ToggleGroupItem>    <ToggleGroupItem value="italic" aria-label="Italic">I</ToggleGroupItem>    <ToggleGroupItem value="underline" aria-label="Underline">U</ToggleGroupItem>  </ToggleGroup>  <ToggleGroup type="multiple" size="sm" aria-label="Text formatting">    <ToggleGroupItem value="bold" aria-label="Bold">B</ToggleGroupItem>    <ToggleGroupItem value="italic" aria-label="Italic">I</ToggleGroupItem>    <ToggleGroupItem value="underline" aria-label="Underline">U</ToggleGroupItem>  </ToggleGroup>  <ToggleGroup type="multiple" size="md" aria-label="Text formatting">    <ToggleGroupItem value="bold" aria-label="Bold">B</ToggleGroupItem>    <ToggleGroupItem value="italic" aria-label="Italic">I</ToggleGroupItem>    <ToggleGroupItem value="underline" aria-label="Underline">U</ToggleGroupItem>  </ToggleGroup>  <ToggleGroup type="multiple" size="lg" aria-label="Text formatting">    <ToggleGroupItem value="bold" aria-label="Bold">B</ToggleGroupItem>    <ToggleGroupItem value="italic" aria-label="Italic">I</ToggleGroupItem>    <ToggleGroupItem value="underline" aria-label="Underline">U</ToggleGroupItem>  </ToggleGroup>  <ToggleGroup type="multiple" size="xl" aria-label="Text formatting">    <ToggleGroupItem value="bold" aria-label="Bold">B</ToggleGroupItem>    <ToggleGroupItem value="italic" aria-label="Italic">I</ToggleGroupItem>    <ToggleGroupItem value="underline" aria-label="Underline">U</ToggleGroupItem>  </ToggleGroup></div>

Disabled

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.

Density
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
<ToggleGroup type="multiple" defaultValue={["bold"]} aria-label="Formatting">  <ToggleGroupItem value="bold">Bold</ToggleGroupItem>  <ToggleGroupItem value="italic" disabled>Italic</ToggleGroupItem>  <ToggleGroupItem value="underline">Underline</ToggleGroupItem></ToggleGroup>