Button group
Joins a few related actions into one control.
Examples
Orientation
horizontaldefault
vertical
Content
buttons
with text
with menu
with separator
States
rest
hover
focus
disabled
menu open
pending
Usage
When to use it
- Use
- Two to four actions work on the same thing: zoom out, fit, zoom in.
- Use
- One main action has close alternatives most people never need: Send reply, plus Send and close.
- Use
- A value sits between two buttons that change it: previous page, 2 of 9, next page.
- Not for
- The person picks one option and it stays picked. Use Segmented control.
- Not for
- The actions are unrelated and only share a row. Use Button.
- Not for
- More than four actions, or alternatives that are not variants of one action. Use Dropdown menu.
Rules
9 rules
- Must
- Pass the main action pending to ButtonGroupMenu so the caret locks while it runs. Because an open caret mid-run lets the person start a second variant of the same reply.
- Must
- Give every member the same variant and size. Because the shared line and the 1px paper split only read as one control when the fills match, and mixed heights break the squared inner corners.
- Should
- Keep text members to three in a 320px column; move the rest into a ButtonGroupMenu. Because the group is w-fit and never wraps, so a fourth label pushes it past a phone column.
- Must
- Set the menu label to what the alternatives are, such as "More send options". Because label is the caret's only accessible name, and "More" does not say more of what.
- Should
- Start each menu item with the main verb and describe what differs.
- Must
- Pass focusableWhenDisabled on a member that disables at a limit and announce the value in a status line. Because a plain disabled button drops focus to the page body when the count hits its limit.
- Must
- Name the group with aria-label when its members do not say what they share. Because ButtonGroup sets role="group", and a screen reader announces an unnamed group as just "group".
- Never
- Add a border to each member; the group draws one shared line, and ButtonGroupSeparator divides ghost members. Because a border doubles where two members meet and grows the member, so the group no longer lines up with a lone button.
- Never
- Build view switching from a ButtonGroup and a highlighted member; use SegmentedControl. Because a highlighted Button has no pressed or selected state, so a screen reader cannot tell which view is on.
Accessibility
6 checks
- The root is role="group"; Tab moves between members, arrows do not.
- The caret is a menu button with aria-haspopup and aria-expanded, named by label.
- Name the group when the members do not say what they share.
- A member that disables at a limit keeps focus with focusableWhenDisabled.
- The focused member sits above its neighbours so the ring stays whole.
- Escape closes the menu and returns focus to the caret.
Edge cases
11 edge cases
- Long member label
- Labels never wrap, so the group widens; the menu caps at --popover and wraps descriptions.
- Icon-sm members
- 28px cells sharing one control line.
- No items in the menu
- Opens an empty popup; do not render ButtonGroupMenu with no items.
- Four members in 320px
- The joined row does not wrap and overflows; the menu shifts 8px inside the viewport.
- Right to left
- Corners are logical; the group mirrors whole.
- Zoom 200%
- Members stretch to the tallest; the joined shape holds.
- Main action pending
- The caret disables too and the box keeps its width.
- Failed alternative
- Report beside the group; the group has no error state.
- Escape in a menu inside a dialog
- Closes only the menu.
- Forced colors
- Each member draws a ButtonText border; the separator paints CanvasText.
- Reduced motion
- The caret turns without a transition; the menu appears at once.
API
9 props
ButtonGroup orientation'horizontal' | 'vertical'default'horizontal'ButtonGroup aria-labelstringButtonGroupMenu labelstringdefaultrequiredButtonGroupMenu variant, size'primary' | 'secondary' | 'ghost', 'sm' | 'default' | 'lg'default'primary', 'default'ButtonGroupMenu pendingbooleanButtonGroupMenu align'start' | 'end'default'end'ButtonGroupMenuItem descriptionReactNodeButtonGroupMenuItem variant'default' | 'destructive'default'default'ButtonGroupSeparator orientation'vertical' | 'horizontal'default'vertical'
Tokens
16 tokens
--space-2- 8px
--line-control- oklch(var(--ink-c) / 0.16)
--line-row- oklch(var(--ink-c) / 0.08)
--surface-page- var(--paper)
--text-2- oklch(var(--ink-c) / 0.80)
--type-label- 400 14px/16px var(--font-sans)
--radius-soft- 6px
--line-hair- 1px
--paper- oklch(var(--paper-c))
--elev-overlay- 0 0 0 1px var(--line-frame), 0 12px 24px -12px oklch(var(--text-on-field-c) / var(--shadow-alpha))
--popover- 288px
--dur-base- 160ms
--dur-fast- 120ms
--ease-step- cubic-bezier(0.2, 0, 0, 1)
--surface-hover- oklch(var(--ink-c) / 0.05)
--fail-text- oklch(51% 0.180 26)