ButtonGroup
A cluster of related but independent actions — in a shared pill container or attached into one connected control, horizontal or vertical
Playground
Installation
pnpm add @tessinaui/uiUsage
import { ButtonGroup, ButtonGroupItem } from "@tessinaui/ui";<ButtonGroup variant="outline" size="md" aria-label="Clipboard actions">
<ButtonGroupItem leadingIcon={<Scissors />} label="Cut" />
<ButtonGroupItem leadingIcon={<Copy />} label="Copy" />
<ButtonGroupItem leadingIcon={<Clipboard />} label="Paste" />
</ButtonGroup>Every item is an independent action with its own tab stop. Name the group —
aria-label, or aria-labelledby pointing at a visible heading.
When to use which
ButtonGroup is for independent actions that happen to belong together.
Nothing in it is selected, and pressing one does not change the state of the
others. If that is not your case, one of its neighbours fits better:
| You want | Use | Why |
|---|---|---|
| Related actions in one cluster — Cut / Copy / Paste, a toolbar | ButtonGroup | Each item does something; none is "on" |
| One exclusive choice — Day / Week / Month, Light / Auto / Dark | SegmentedControl | A selection track with a moving thumb and radio semantics |
| A set of on/off states — Bold / Italic / Underline | ToggleGroup | Items are pressed or not, and stay that way |
| Filter or answer chips — pick a category, pick up to two tags | Chip / ChipGroup | Selection chips, spaced, wrapping |
| One primary action plus related alternatives | SplitButton | The menu belongs to the button |
| A dense, persistent action bar with one tab stop | Toolbar | Roving arrow-key focus, which a group deliberately does not do |
The name is the trap: most interfaces that look like a "button group" are actually a segmented control or a row of choice chips.
Examples
Default
A horizontal group of pill buttons with leading icons and labels.
Variants
Two visual styles — outline with a container background and ghost with a transparent one.
Intents
All five house intents are available, on both variants and both tones. Intent tints the item's ink and its state layers — it never changes the container, so a group stays one object.
A group is usually neutral with a single item carrying the intent, which is how every surveyed product renders a destructive action among its siblings. Set it on the group only when the whole cluster shares one meaning.
An item that collapses into the overflow menu keeps error but renders neutral
for the other intents — menu rows carry none | error only.
Attached
Set attached to collapse the items into one connected control: no plate, no
gap, inner corners flattened and a single hairline seam between neighbours. This
is the shape most design systems ship as their only button group; here it is
opt-in, and the pill container stays the default.
An outline group collapses the doubled border where two items meet. A ghost
group has no border to collapse, so it gains a hairline — without one, two
attached items on the same fill have no visible boundary at all.
Sizes
Five sizes from xs to xl scale height, padding, and icon footprint together —
the same scale Button uses, so a md group lines up with a md button.
Rounded
Six steps from none to full. The container's corner sits one token above the
items' so they stay tucked inside it, and a vertical pill derives its corner from
the item height rather than rounding to an oval.
Vertical
Set orientation="vertical" to stack items into a column — the shape of a
canvas-tool rail or a narrow side panel.
Full width
fullWidth stretches the group to its container. Only the container's sizing
changes — items stay equal-width and still shrink rather than overflow.
Text cell
ButtonGroupText puts a non-interactive readout inside the run — a zoom level,
a unit, a count. It takes the same box as an item so the run keeps one height,
and differentiates by having no states at all: no hover, no press, no focus
ring. It is not a tab stop, and its text is read as part of the group.
Separator
ButtonGroupSeparator divides a run into clusters without splitting the
container. It is decorative and draws the same AA-gated hairline the attached
seam uses. An attached group already draws its own seams, so a separator
there paints a second line — it is for capsule and ghost runs.
Overflow
overflow="collapse" moves whatever does not fit into a trailing More
actions menu, in order, instead of letting the run shrink. The group measures
the space its container gives it, so the cut follows the real layout rather than
a breakpoint. Nothing is lost: every hidden action is in the menu, by name.
A loading item collapses like any other and lands disabled — a busy action is not invocable, and keeping it pinned would reorder the strip mid-interaction.
Responsive stacking
stackOn="auto" turns the group into a column when its container is too narrow
for the run — a phone, and equally a narrow desktop column, which a breakpoint
cannot see. The group stamps data-stacked and reports the orientation it is
actually in, so styles and tests can key on the truth.
Before hydration it renders as a row and shrinks, exactly as it does today; the
stack lands in a layout effect, before paint. stackOn and overflow are
mutually exclusive — collapsing exists precisely to avoid reflowing the run.
Icon only
Drop the label and provide an aria-label for compact icon-only toolbars.
On a coloured surface
Inside a Surface, items pick up tone="on-color" automatically and paint from
the surface's own ink instead of the page palette.
States
A loading item, a single disabled item, and a fully disabled group. A loading
item stays focusable and reports aria-busy — it is busy, not unavailable.
Skeleton
ButtonGroupSkeleton takes its box from the same styles as the real group, so
the placeholder cannot drift from the control it stands in for.
API Reference
ButtonGroup Props
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "outline" | "ghost" | "outline" | Visual weight applied to all items |
intent | "none" | "error" | "warning" | "success" | "info" | "none" | Semantic meaning applied to all items. A single destructive action usually sets intent on the item instead |
size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | Size applied to all items |
rounded | "none" | "sm" | "md" | "lg" | "xl" | "full" | "full" | Corner radius; items follow the container's silhouette |
tone | "default" | "on-color" | inherited | Whether the group sits on a coloured surface. Inherited from a parent Surface when unset |
orientation | "horizontal" | "vertical" | "horizontal" | Layout direction of the group |
attached | boolean | false | Collapse the items into one connected control — no plate, no gap, flattened inner corners, one hairline seam |
fullWidth | boolean | false | Stretch the container to its parent; items stay equal-width |
overflow | "collapse" | — | Move whatever does not fit into a trailing "More actions" menu. Horizontal only; mutually exclusive with stackOn |
overflowLabel | string | "More actions" | Accessible name for the overflow trigger |
stackOn | "never" | "auto" | "never" | auto stacks the group into a column when its container is too narrow. Horizontal only |
gap | "micro" | "none" | "micro" | Gap between items — micro is 4px horizontally, 8px vertically. Ignored while attached |
showItemBorder | boolean | false | Draw a visible border on every item. Ignored while attached, where the border is the seam |
disabled | boolean | false | Disables all items in the group at once |
dir | "ltr" | "rtl" | "ltr" | Text direction — RTL reverses item and icon order |
ButtonGroupItem Props
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | — | Visible label text |
leadingIcon | React.ReactNode | — | Icon rendered before the label |
trailingIcon | React.ReactNode | — | Icon rendered after the label |
variant | "outline" | "ghost" | group's | Overrides the group's weight for this item alone |
intent | "none" | "error" | "warning" | "success" | "info" | group's | Overrides the group's intent for this item alone — how a destructive action sits among neutral siblings |
loading | boolean | false | Shows a spinner over the label. The item stays focusable and reports aria-busy; activation is suppressed |
disabled | boolean | false | Disables this item individually |
type | "button" | "submit" | "reset" | "button" | Defaults to button, so an item inside a form cannot submit it by accident |
aria-label | string | — | Accessible label (required when no visible label is provided) |
onClick | MouseEventHandler | — | Click handler |
ButtonGroupText Props
A non-interactive cell. Takes children plus any div attribute; height,
padding, type scale, radius and border all come from the group.
ButtonGroupSeparator Props
Decorative divider. Takes any div attribute; its axis follows the group's
orientation.
ButtonGroupSkeleton mirrors the group's size, variant, orientation,
gap, rounded, attached and fullWidth, plus count, labels,
showLeadingIcon and showTrailingIcon.
Variants
Variant options
| Variant | Use |
|---|---|
outline | Default. Items sit transparent on the group's container fill |
ghost | No container fill; items paint only on hover and press |
Intent options
All five house intents are available on the group and per item. Intent tints the item's ink and its state layers; it never changes the container.
| Intent | Use |
|---|---|
none | Default. Neutral actions |
error | Destructive — delete, remove, revoke |
warning | Actions with a caveat |
success | Confirming, approving |
info | Informational |
Size options
| Size | Item height | Container height | Use case |
|---|---|---|---|
xs | 32px (44px on phones) | 36px | Compact toolbars |
sm | 40px (44px on phones) | 44px | Secondary actions |
md | 48px | 56px | Default, most use cases |
lg | 56px | 64px | Prominent toolbars |
xl | 96px | 104px | Hero / large controls |
xs and sm sit below the 44×44px WCAG 2.5.5 touch floor, so they grow to it on
phones and tablets and keep the compact desktop density from md up. These are
the same heights Button uses, so the two line up when placed side by side.
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 ButtonGroup, a ToggleGroup, a
SegmentedControl and a Stepper sitting side by side read as one family. Container
height is the item height plus twice that inset; an attached group has no inset,
so its height is the item height.
Rounded options
| Rounded | Item | Container |
|---|---|---|
none | Square | Square |
sm | 4px | 6px |
md | 6px | 8px |
lg | 8px | 12px |
xl | 12px | 16px |
full | Pill | Pill |
The container's corner clears the item's by the inset it holds, so items never
read as rectangles floating inside a rounded plate. A vertical full group
derives its corner from the item height instead of clamping to its own width,
which would round a tall column into an oval. While attached there is no inset,
so the container and the run's outer corners are the same radius.
Accessibility
- The wrapper renders
role="group". Name it witharia-label, oraria-labelledbypointing at a visible heading. - Every item is its own tab stop. These are independent actions, so there is
no roving arrow-key focus — that contract belongs to
Toolbar, and taking it on would oblige arrow-key navigation the group does not implement. - Icon-only items require
aria-label; the icon itself is hidden from assistive technology. - The focus ring is the house geometry (
ring-2plus a 2px offset in the surface colour) and lifts above its neighbours so it is never clipped — including inattached, where the wrapper opens a stacking context for exactly that reason. - Touch targets are ≥44×44px on phones and tablets at every size.
loadingkeeps the item focusable and setsaria-busywitharia-disabledrather than the nativedisabledattribute — a control that natively disables itself mid-interaction throws focus to the page body. Clicks and the activation keys are suppressed; Escape still reaches your handler. The label stays mounted, so the item keeps its accessible name while busy.disabled(item or group) uses the native attribute and removes the item from the tab order. When both are set,disabledwins.- An attached
ghostgroup draws a hairline between items in the AA-gated outline token, so neighbouring controls are always distinguishable. - RTL is fully supported: attached corners use logical properties, so a horizontal run mirrors without a second set of styles.
- A
ButtonGroupTextcell is not focusable and has no role — it is read as part of the group's content, which is what a zoom level or a unit should be. ButtonGroupSeparatorisaria-hidden; grouping is visual, and the group's own accessible name carries the meaning.- Under
overflow="collapse"no action is lost. Whatever does not fit moves, by name, into a menu opened from a named More actions button — reachable by keyboard like any other menu. Rename it withoverflowLabelwhen the group is one of several on a page, or to localise it.