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.
pnpm add @tessinaui/uiWhen to use
- Independent on/off states such as bold, italic and underline: use
multiple, and each item toggles like a checkbox. - A view or mode switcher where the choice applies immediately: use
requireSelectionso the group is never empty. - A closely related set of a handful of options that read as one control, all labelled or all icon-only, never mixed. A mixed row gives the eye no consistent scan line and the hand no consistent target width.
- Spaced-pill or ghost skins, or a group where deselecting is legal, which SegmentedControl does not offer.
- In-page state. Items are buttons with
aria-pressed, never links.
When not to use
- For exactly-one-of-N with the sliding thumb-on-track look and radio semantics. Use SegmentedControl.
- For a scrollable row of filter choices with checkmarks and overflow. Use Chip.
- To switch between panels of distinct content, or anything addressable by URL. Use Tabs; navigation belongs there.
- For a cluster of independent actions (buttons that do things, not states). Use ButtonGroup.
- For a single on/off. Use ToggleButton, or Switch for a persistent setting.
- For many options. Long lists belong in a Select.
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>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, and a
keyboard user has to be given the same hint a hover gives. 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: the view
is always in one of these states, so an empty group would misreport it.
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 (a pressed item is the solid inverse fill), 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.
On color
Inside a coloured Surface the items inherit on-color, and the outline track paints the plate's well instead of the page's grey. A ghost group paints no track.
Skeleton
RTL
Under dir="rtl" the items run right to left and ← moves to the next one. Unset, the group follows the page: on an RTL page the layout and the arrow keys both mirror, with no dir to pass.
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" | inherited | Writing direction, arrow keys included. Unset, the group follows the page; set, it is stamped on the root |
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 and tablets, xs and sm items grow to the 44px touch floor, and a
vertical rounded="full" group's stadium follows that height.
Accessibility
VoiceOver + Safari: passed 2026-09-14. The group reads "Alignment, group"; items read "Left, selected, toggle button" and "Center, toggle button". Space is silent at the press (as for any aria-pressed button), and re-reading shows the move: "Left, toggle button", "Center, selected, toggle button". NVDA and JAWS are untested.
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. A toggle button carries its own pressed state, which is what in-page state that is not a form answer needs; a tablist-flavoured switcher is a different shape, 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, which changes the target's identity mid-interaction - 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 and tablets for
xs/sm, larger sizes exceed the floor