ToggleGroup
A group of toggle buttons — a multi-select toolbar set or a single-select switcher — built on Base UI's ToggleGroup primitive with aria-pressed semantics.
Playground
Installation
pnpm add @tessinaui/uiUsage
import { ToggleGroup, ToggleGroupItem } from "@tessinaui/ui";<ToggleGroup aria-label="Alignment" defaultValue={["left"]}>
<ToggleGroupItem value="left" leadingIcon={<AlignLeft />} label="Left" />
<ToggleGroupItem value="center" leadingIcon={<AlignCenter />} label="Center" />
<ToggleGroupItem value="right" leadingIcon={<AlignRight />} label="Right" />
</ToggleGroup>When to use which
Most "toggle group"-looking UI is actually one of five components. The name collides with its neighbours, so pick by semantics, not by looks:
| You want | Use |
|---|---|
| A set of on/off states — independent toggles (bold/italic/underline), or a switcher where deselecting is legal or the spaced-pill / ghost skin is wanted | ToggleGroup |
| Exactly-one-of-N with the sliding thumb-on-track look and radio semantics | SegmentedControl |
| A scrollable row of filter choices with checkmarks and overflow | Chip / ChipGroup |
| Switching between panels of distinct content, possibly URL-addressable | Tabs |
| A cluster of independent actions (buttons that do things, not states) | ButtonGroup |
Two rules the survey systems agree on: toggle-group items are in-page state, never links (Primer states it outright — navigation belongs to Tabs), and a group is all-labelled or all-icon, never mixed (Carbon, Primer, HIG).
Examples
Default
A single-select alignment group with the default outline variant.
Variants
Two container styles — outline (filled background) and ghost (transparent).
Sizes
Five labelled sizes from xs to xl, plus square icon.
Icon only
size="icon" renders square, icon-only items — the toolbar view-switcher
pattern. Every item must carry its own aria-label, and an item with no
visible text of its own shows that name as a tooltip on hover and keyboard
focus — automatically, because a glyph alone is not self-explanatory (Primer
and Spectrum both require a tip for exactly this case). Pass tooltip to
override the text, or tooltip={false} to opt out.
Multiple Selection
With multiple, each item toggles independently like a set of checkboxes.
Required selection
requireSelection keeps single-select groups from ever being empty — pressing
the only selected item is a no-op. This is the view-switcher contract (React
Aria's disallowEmptySelection; M3 requires a selection in segmented buttons).
Full width
fullWidth stretches the track to its container; labelled items share the
width equally — the mobile fulfilment/billing pattern.
Intents
All five house intents tint the pressed state.
Rounded
The radius cascade, including the derived stadium a vertical full group
takes from its own item height.
Rich items
Items accept arbitrary children — a label with a sublabel, or a badge beside the text.
Do not reach for text-muted-foreground in a sublabel here. An item sits on
the group's own secondary track, and muted ink composites to 3.2–4.0:1 there
(measured, both themes) — under AA. Use text-foreground/75, the same step
Accordion, BottomNav and Breadcrumb took for the identical trap.
Days of week
Multi-select single-character cells (the repeat-schedule pattern) as a flush
track — seven 44px touch targets plus gaps cannot fit a 320px viewport, so
gap="none" is the phone-safe shape. The visible letter is ambiguous, so each
item's accessible name is the full day.
Time range
A dense ghost group for chart ranges — only the pressed item paints.
Toolbar composition
A multiple format group beside a requireSelection alignment group — two
groups, two accessible names, one visual row.
Vertical
Stack items vertically with orientation="vertical".
States
A loading item, plus a fully disabled group.
Skeleton
API Reference
ToggleGroup Props
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "outline" | "ghost" | "outline" | Visual style applied to all items |
intent | "none" | "error" | "warning" | "success" | "info" | "none" | Semantic color intent applied to all items |
size | "xs" | "sm" | "md" | "lg" | "xl" | "icon" | "md" | Size applied to all items; icon renders square icon-only items |
rounded | "none" | "sm" | "md" | "lg" | "xl" | "full" | "full" | Corner radius for the container and every item; vertical full derives a stadium |
orientation | "horizontal" | "vertical" | "horizontal" | Layout direction of the group |
gap | "micro" | "none" | "micro" | Gap between items — micro is 4px (horizontal) / 8px (vertical), none is 0px |
fullWidth | boolean | false | Stretch the track to its container; labelled items share the width equally |
multiple | boolean | false | When true, multiple items can be pressed at once; when false, at most one item is pressed |
requireSelection | boolean | false | Single-select only: the last pressed item cannot be toggled off. Ignored (with a dev warning) under multiple |
value | string[] | — | Controlled array of pressed item values |
defaultValue | string[] | [] | Initial pressed values for uncontrolled usage |
onValueChange | (value: string[], eventDetails) => void | — | Fired when the pressed set changes; never fired with [] while requireSelection holds |
loopFocus | boolean | true | Whether arrow-key focus wraps around the group |
disabled | boolean | false | Disables every item in the group at once |
dir | "ltr" | "rtl" | — | Unset, the group follows the document. When set it is stamped on the root and provided through Base UI's DirectionProvider, so arrow-key order actually flips |
ToggleGroupItem Props
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Required. Identifier used in the group's value array when the item is pressed |
label | string | — | Visible label text |
children | React.ReactNode | — | Rich content (sublabel, badge…). Wins over label when both are given |
leadingIcon | React.ReactNode | — | Icon before the label; stays mounted while loading (the spinner overlays it) |
trailingIcon | React.ReactNode | — | Icon after the label; stays mounted while loading |
loading | boolean | false | Shows a spinner, sets aria-busy, and refuses activation — the item stays focusable |
disabled | boolean | false | Disables this item individually |
tooltip | React.ReactNode | false | — | Hover/focus tip. An icon-only item (no label, no children) shows its aria-label automatically; pass a node to override, or false to opt out |
aria-label | string | — | Accessible name — required for every item in a size="icon" group |
Items also forward arbitrary HTML attributes (data-*, aria-describedby,
test ids) to the rendered <button>.
Single vs Multiple
By default ToggleGroup is single-select (multiple={false}) — pressing a
new item unpresses the previous one, and pressing the current item deselects it
(the group may be empty). Add requireSelection when a view must always be
chosen. The value prop is an array in both modes so they share one shape:
// Single select, never empty — a view switcher
const [view, setView] = useState<string[]>(["list"]);
<ToggleGroup requireSelection value={view} onValueChange={setView} aria-label="View">
<ToggleGroupItem value="list" leadingIcon={<List />} aria-label="List" />
<ToggleGroupItem value="map" leadingIcon={<MapTrifold />} aria-label="Map" />
</ToggleGroup>// Multi select — each item toggles independently
const [format, setFormat] = useState<string[]>([]);
<ToggleGroup multiple value={format} onValueChange={setFormat} aria-label="Formatting">
<ToggleGroupItem value="bold" leadingIcon={<Bold />} aria-label="Bold" />
<ToggleGroupItem value="italic" leadingIcon={<Italic />} aria-label="Italic" />
<ToggleGroupItem value="underline" leadingIcon={<Underline />} aria-label="Underline" />
</ToggleGroup>Supported Combinations
| Variant | Intents |
|---|---|
outline | none, error, warning, success, info |
ghost | none, error, warning, success, info |
Sizes
Every grouped control insets its items from the container edge by the same
shared scale — 2px at xs/sm, 4px from md up — so a ToggleGroup, a
SegmentedControl and a Stepper sitting side by side read as one family.
Container height is therefore the item height plus twice that inset.
| Size | Item height | Container height | Use case |
|---|---|---|---|
xs | 32px | 36px | Compact toolbars |
sm | 40px | 44px | Secondary actions |
md | 48px | 56px | Default, most use cases |
lg | 56px | 64px | Prominent toolbars |
xl | 96px | 104px | Hero / large controls |
icon | 48×48px | 56px | Icon-only view switchers, day cells |
On phones, xs and sm items grow to the 44px touch floor, and a vertical
rounded="full" group's stadium follows that height.
Accessibility
The ToggleGroup component:
- Built on Base UI's
ToggleGroup+Toggleprimitives - Renders a
role="group"container — give it an accessible name viaaria-label/aria-labelledby(the component dev-warns without one) - Each item is a
<button aria-pressed>in both modes — neverrole="radiogroup"/aria-checked. This is the unanimous model across Base UI, Radix and React Aria; Carbon's tablist-flavoured content switcher is the documented outlier, and that shape belongs toTabs/SegmentedControlhere - Item labels never change with pressed state (the APG toggle-button rule);
selection is conveyed by
aria-pressedand the pressed fill — never by swapping icons (Primer's rule) - An icon-only item's tooltip is a sighted affordance on top of the
accessible name it already carries — it opens on keyboard focus as well as
hover, and it never becomes the name, so the button announces identically
with or without it. Touch has no hover: the
aria-labelis what carries the meaning there, which is why it stays required - Keyboard: Tab enters the group (one stop — roving tabindex), Arrow
keys move focus (wrapping via
loopFocus), Enter / Space toggle - RTL: set
dir="rtl"on the group — the attribute alone is not enough for Base UI, so the component also provides direction context, flipping arrow-key order along with the layout loadingsetsaria-busyand refuses activation while keeping the item focusable — never nativedisabledmid-interactiondata-slot="toggle-group"/data-slot="toggle-group-item",data-variant,data-size,data-gap,data-full-width,data-pressedexpose deterministic hooks for CSS, tests and agents- Focus ring meets WCAG 2.1 AA on each item (2px ring + 2px offset); touch
targets are ≥44×44px on phones for
xs/sm, larger sizes exceed the floor