Skip to content
garn

Button Group

Attaches related buttons into one segmented cluster, and hosts the split button.

Stablev0.4.2added in v0.1.0@garn/ui/button-group
On this page

Default

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.

Split Button

Split Button

The canonical split button: primary action, derived seam, and a contextually-named menu half.

Split Button Appearances

The seam across fills and tones — derived from the fill on solids, the input-border family on the airy ones.

Group 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.

terminal
npx garn-ui add button-group
First time? Set up garn in your project
Registry
button-group
Deps
class-variance-authority
Registry deps
utilsbuttonuse-merged-ref

Import

import { SplitButton, ButtonGroupText } from "@garn/ui/button-group";

Anatomy

The parts this component is built from — compose them to assemble it.

SplitButton[data-slot="split-button"]

A primary action attached to a menu of related ones. Takes variant (default | soft | outline | ghost | link), tone (neutral | brand | danger | success | warning), size (defaults to md, so the context always carries a rung), and disabled — the same four Button axes, handed to both halves through context; a prop set on a half wins, except disabled, which ORs. Reflects all three enum axes as data-variant / data-tone / data-size on its root, because the seam is coloured from the wrapper. Owns that seam and expects exactly two action children.

ButtonGroupText[data-slot="button-group-text"]

A static text segment inside a ButtonGroup — a count, a unit, a prefix. Takes size (xs | sm | md | lg | xl, default md); match it to the buttons beside it so the strip keeps one height.

Props

The component's public props and their 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 states, 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"]

States

SelectorState
[&>*:hover]hover
[&>*:focus-visible]focus-visible
[data-state="open"]menu-open
PropertyTokenTier
height--garn-control-h-mdsemantic · air

Private instance vars (never externalize): --btn-fill--btn-px

When to use

Bind related actions into one attached cluster — toolbars, segmented controls, and split buttons (a primary action plus a menu of related ones).

Reach for something else when

  • For unrelated actions — space them apart with plain buttons and a gap. Inside a role="toolbar", don't use SplitButton at all: it isn't a roving item. And for choosing among options rather than firing actions, use ToggleGroup.

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

Do
  • Use the same size on 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 size off an icon-only chevron half — it derives the matching square rung.
Don't
  • 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.

Troubleshooting

Segment corners or seams look broken.

Cause. Children with different sizes/radii don't share an edge.

Fix. Give every child the same size; ButtonGroup collapses inner radii for you.

A split button's seam is the wrong colour — it stayed neutral while the halves went brand (and the console says so).

Cause. tone/variant was set on a half. The seam is a SIBLING of the halves and cannot read a half's --btn-fill, so it is derived from the wrapper's axes.

Fix. Set tone/variant on the SplitButton wrapper; it hands them to both halves through context.

<SplitButton disabled> leaves the chevron half still clickable — or a half's disabled={false} doesn't re-enable it.

Cause. Deliberate: disabled ORs down instead of falling through, the same containment fieldset[disabled] has. It is load-bearing — a menu trigger passes an explicit disabled={false} to the half it wraps, and fall-through would silently undo the wrapper.

Fix. Disable at the level you mean. A half can be more disabled than its wrapper, never less; to enable one half, don't disable the wrapper.

The chevron half renders as a wide, text-padded button instead of a square one.

Cause. An explicit size on the half overrode the derived rung.

Fix. Leave size off the icon-only half — with no text content it resolves to the SQUARE rung matching the wrapper's (md→icon, lg→icon-lg).

Tab order goes strange, or arrow keys skip a split button inside a toolbar.

Cause. A SplitButton is not a roving toolbar item: its halves keep tabIndex 0, and it doesn't count toward the toolbar's 3-control roving threshold — so it can silently downgrade the whole toolbar to a plain group.

Fix. Use the toolbar's own button parts, or move the split button outside the toolbar. A first-class toolbar split-button part is future work.

A group stretches to the full width (or height) of a flex parent.

Cause. As a flex item an inline-flex box is blockified, so align-items: stretch sizes it to the cross axis. The base now sets w-fit to opt out.

Fix. Nothing to do by default. If you DO want the full width, w-full in className still wins through twMerge.

An arbitrary Tailwind variant targeting the open menu half emits no CSS at all — silently, with no error.

Cause. Two chained attribute selectors in one arbitrary variant ([&_[data-slot=button][data-state=open]_svg]) are not parsed. Single-attribute forms work.

Fix. Use one attribute per variant — inside a split button, [aria-haspopup] or [data-state=open] each single out the menu half on their own.

Accessibility

Role
group
ARIA APG
menu-button

Accessibility requirements

error

Renders two native <button> siblings — never a button nested in a button — and both are reachable by Tab, in DOM order.

when SplitButton

error

aria-haspopup / aria-expanded appear only on the chevron half, never on the primary half or the wrapper.

when SplitButton with a menu half

error

Escape closes the menu and returns focus to the chevron half.

when SplitButton with an open menu

error

`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

error

A loading primary half leaves the chevron half clickable and focusable — the wrapper never links the two.

when SplitButton with a loading primary half

error

The separator carries data-slot="split-button-separator" and aria-hidden, and renders a real border in forced-colors mode.

when SplitButton

error

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

warn

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

warn

Pass exactly two halves: the primary button, then the menu half.

when SplitButton with other than two action children

warn

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

warn

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

TabMoves 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.
EnterSpaceActivates the focused segment; on the chevron half, opens the menu.
EscapeCloses an open menu and returns focus to the chevron half.
A2.1.1KeyboardA2.5.3Label in NameA4.1.2Name, Role, ValueAA2.5.8Target Size (Minimum)
  • 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.