Button Group
Attaches related buttons into one segmented cluster, and hosts the split button.
@garn/ui/button-groupDefault
A horizontal segmented cluster.
Spacing
The same control rendered across garn's density modes — compact, default, and spacious.
Orientation
Direction the segments attach; collapses the adjacent inner radii and overlaps the 1px seams. Inline-axis utilities are logical, so an RTL group merges from the correct end.
horizontalverticalGroup Text
A ButtonGroupText segment carrying a count between two buttons.
Installation
garn is copy-in — the garn CLI writes this component's source into your project, so you own and can edit it. It resolves any base files and installs the npm dependencies below for you.
npx garn-ui add button-group- Registry
button-group- Source
- button-group.tsx
- Deps
class-variance-authority- Registry deps
utilsbuttonuse-merged-ref
Import
import { ButtonGroup } from "@garn/ui/button-group";Props
Generated from the component's TypeScript types.
orientation- Description
- No description.
- Type
"horizontal" | "vertical"- Default
horizontal
Plus 280 inherited native <div> attributes.
SplitButton
childrenRequired- Description
- Exactly two halves: the primary action, then the menu half. The menu half is normally a whole `DropdownMenu` whose `DropdownMenuTrigger asChild` wraps an icon `Button` — the trigger, not this wrapper, owns the menu ARIA.
- Type
React.ReactNode
Plus 283 inherited native HTML attributes.
Styling
Target these data-slots and remap these tokens to restyle without forking the component.
[data-slot="button-group"][data-slot="split-button"][data-slot="split-button-separator"][data-slot="button-group-text"]| Property | Token | Tier |
|---|---|---|
height | --garn-control-h-md | semantic · air |
Private instance vars (never externalize): --btn-fill--btn-px
Overview
ButtonGroup binds related actions into one attached cluster — a toolbar segment, a segmented control, a split button. It collapses the seams between adjacent buttons so they read as a single unit while each stays a real button. Use it when the actions are genuinely related and benefit from being visually joined; for unrelated actions, space plain buttons apart with a gap. For picking one option from a set, a ToggleGroup is the stateful cousin.
It also hosts garn's split button. SplitButton is a thin wrapper: it shares variant/tone/size/disabled with its two halves through context — so the pair can't drift apart — and owns the seam between them, drawn from the active fill rather than a generic border token (a gray line reads as a scratch across a brand or danger solid). Both halves stay separate native buttons, which makes the cluster two tab stops and keeps the menu ARIA on the chevron half where it belongs. Leave size off the chevron half: an icon-only child derives the square rung matching the wrapper's, so the two can never mismatch in height.
For a static segment between the buttons — a count, a unit, a prefix — ButtonGroupText is cut from the same strip: same height token, same border, same collapsed inner radii.
Guidelines
Group only actions that belong together. The attached look signals relatedness — peer actions on one object, or steps of a split button. Unrelated actions joined into a bar imply a relationship that isn't there; separate them.
Keep the cluster visually and semantically even. Use one size and a consistent variant treatment across the group, with at most one emphasized action; a row of equally-filled buttons has no focal point. Order them by likelihood or convention.
Preserve each button's affordances. Every segment keeps its own label, focus ring, and disabled state — don't merge them into one ambiguous control. For a single-choice or multi-select selector, reach for ToggleGroup instead so state is modeled properly.
Set a split button's axes on the wrapper. variant, tone, size, and disabled belong on SplitButton, which hands them to both halves. Setting one on a half instead desyncs the seam — it is a sibling of the halves and can only read the wrapper's fill — and you'll get a dev warning saying so. The one thing to put on the half is the menu trigger's aria-label, naming what the menu opens.
Best practices
- Use the same
sizeon every child so the seams align. - Give the group an aria-label.
- Set a split button's variant/tone/size/disabled on the SplitButton wrapper, not on a half.
- Give the chevron half a contextual aria-label (“More save options”).
- Leave
sizeoff an icon-only chevron half — it derives the matching square rung.
- Don't mix wildly different sizes (the 1px seams misalign).
- Don't nest groups within groups.
- Don't put a SplitButton inside a role="toolbar" — it breaks the roving-focus model.
- Don't name the menu half the same as the primary — voice control can't distinguish them.
- Don't expect
disabled={false}on a half to escape a disabled wrapper; the axis contains rather than falls through.
Content guidelines
- Use short, parallel labels across the segments (“Day / Week / Month”).
- Keep the cluster to a small, related set — overflow belongs in a menu.
- Give the group an aria-label that names what it controls.
Accessibility
- Role
group- ARIA APG
- menu-button
Enforceable contracts
Renders two native <button> siblings — never a button nested in a button — and both are reachable by Tab, in DOM order.
when SplitButton
aria-haspopup / aria-expanded appear only on the chevron half, never on the primary half or the wrapper.
when SplitButton with a menu half
Escape closes the menu and returns focus to the chevron half.
when SplitButton with an open menu
`disabled` ORs down rather than falling through: it disables both halves, and a half cannot re-enable itself with disabled={false} — the same containment fieldset[disabled] has. A half may still be disabled on its own while the wrapper is not.
when SplitButton disabled
A loading primary half leaves the chevron half clickable and focusable — the wrapper never links the two.
when SplitButton with a loading primary half
The separator carries data-slot="split-button-separator" and aria-hidden, and renders a real border in forced-colors mode.
when SplitButton
The chevron rotates only when motion-safe applies; under reduced motion the open state still reflects via data-state.
when SplitButton with an open menu
Name the menu half for what it opens (e.g. “More save options”).
when SplitButton whose menu half has no accessible name, or one equal to the primary half's
Pass exactly two halves: the primary button, then the menu half.
when SplitButton with other than two action children
Set tone/variant on the SplitButton, not on a half — the seam is derived from the WRAPPER (a sibling element cannot read a half's fill), so an overridden half desyncs it.
when a SplitButton half whose data-tone/data-variant disagrees with the wrapper's
Don't put a SplitButton in a toolbar. It is not a roving item: its halves keep tabIndex 0 and become extra Tab stops that arrow keys skip, and it doesn't count toward the toolbar's control tally, so it can silently downgrade the whole toolbar to a plain group. Use the toolbar's own button parts, or move the split button outside. A first-class toolbar split-button part is future work.
when SplitButton inside a role="toolbar"
Keyboard
| Tab | Moves through the segments one at a time — a SplitButton is TWO tab stops, one per half. The group has no roving-focus model of its own. |
| EnterSpace | Activates the focused segment; on the chevron half, opens the menu. |
| Escape | Closes an open menu and returns focus to the chevron half. |
- • role=group — pass aria-label naming the group's purpose (e.g. “Text alignment”).
- • Children manage their own focus and roles; the group only attaches them visually.
- • In a SplitButton the menu ARIA (aria-haspopup, aria-expanded) lives ONLY on the chevron half — supplied by the menu primitive through `DropdownMenuTrigger asChild`, never on the primary half or the wrapper. Both halves stay separate native buttons, so no button is ever nested inside another.
- • Give the chevron half a contextual accessible name (“More save options”), not a copy of the primary's — voice control cannot pick between two identically-named controls.
- • The separator is aria-hidden; the chevron svg is aria-hidden.
- Status
- Stable
- Version
v0.3.0- Introduced
v0.1.0
garn doesn't yet record a per-release version history. This reflects the component's current lifecycle state; full changelog entries will appear once the metadata pipeline emits a lifecycle.history field.