Overview
The Button Group component arranges related buttons into a single visual and functional unit. By merging adjacent borders and applying shared border-radius only to the outermost corners, a button group communicates that its actions are related — forming a toolbar, segmented control, or set of mutually exclusive options.
Button groups solve a specific problem: when multiple buttons exist side by side, individual border-radius and margins create visual fragmentation. A button group eliminates this by treating the set as a single compound element — a connected strip where buttons flow seamlessly into each other.
When to use a Button Group:
- For mutually exclusive view options (Grid/List/Map) — functions as a segmented control
- For related actions in a toolbar (Bold/Italic/Underline)
- For pagination controls (Previous/1/2/3/Next)
- For split actions where a primary button has an adjacent dropdown — see Split Button
When NOT to use a Button Group:
- For unrelated actions — use individual Buttons with spacing
- For navigation — use Tabs or a Navigation Bar
- For selecting from many options (>5) — use a Select or Radio Button group
- For toggles between two states — use a Switch
Use the Button Generator to design individual button styles, then compose them into groups. Preview corner rounding on the group container with the Border Radius Generator. Always verify that active/selected states maintain contrast requirements via the Contrast Checker.
Variants
Common Button Group Variants
| Variant | Purpose | Visual Treatment |
|---|---|---|
| Connected | Default joined buttons with merged borders | No gap, inner corners set to 0, outer corners rounded, shared borders collapse |
| Separated | Related but visually distinct buttons | Small gap (2–4px) between buttons, each retains full border-radius |
| Segmented Control | Mutually exclusive selection (acts like radio) | Active button gets filled background, inactive buttons are ghost/outlined |
| Toolbar | Dense action strip for editor/formatting UIs | Icon-only buttons, compact sizing, often includes dividers between groups |
| Vertical | Stacked buttons (mobile, sidebar actions) | Column layout with horizontal borders collapsed |
Size Variants
| Size | Button Height | Icon Size | Use Case |
|---|---|---|---|
| Small | 32px | 16px | Dense UIs, table row actions, secondary toolbars |
| Medium | 40px | 20px | Standard forms, content toolbars |
| Large | 48px | 24px | Hero sections, mobile-first layouts |
Shape Variants
Use the Border Radius Generator to preview these:
| Shape | Radius | Effect |
|---|---|---|
| Rounded | 6–8px (outer corners only) | Professional, enterprise aesthetic |
| Pill | 9999px (outer corners only) | Friendly, modern, consumer-facing — creates a capsule shape |
| Square | 0px | Brutalist, editorial, or when button group touches adjacent elements |
Properties
Button Group Properties
| Property | Type | Default | Description |
|---|---|---|---|
variant | 'connected' | 'separated' | 'segmented' | 'connected' | Visual grouping style |
size | 'sm' | 'md' | 'lg' | 'md' | Size applied to all child buttons |
orientation | 'horizontal' | 'vertical' | 'horizontal' | Stack direction |
value | string | string[] | — | Selected value(s) for segmented control mode |
onChange | (value: string) => void | — | Callback when selection changes (segmented mode) |
exclusive | boolean | true | When true, only one button can be active (radio behavior). When false, multiple selections allowed (checkbox behavior). |
disabled | boolean | false | Disables all buttons in the group |
fullWidth | boolean | false | Stretches the group to fill container width, distributing equally |
children | ReactNode | — | Button elements to render within the group |
Important: When using a button group as a segmented control (selection behavior), it must communicate its role semantically. The group needs role="group" with aria-label, and each button needs aria-pressed="true|false". For exclusive single-selection, consider using role="radiogroup" with role="radio" on each button instead. See the Button component for individual button props.
Token Mappings
Design Token Mappings
| Token Category | Token Example | Button Group Usage |
|---|---|---|
| Color – Active Fill | --color-primary-600 | Selected/active button background in segmented mode |
| Color – Active Text | --color-on-primary | Selected button text color |
| Color – Inactive Fill | transparent or --color-surface-primary | Unselected button background |
| Color – Inactive Text | --color-text-primary | Unselected button text color |
| Color – Border | --color-border-default | Shared border between buttons |
| Color – Divider | --color-border-subtle | Vertical dividers between toolbar sections |
| Spacing – Gap | 0px (connected) / --space-1 (separated) | Gap between buttons |
| Border – Width | --border-width-default (1px) | Button borders — collapsed on internal edges |
| Border – Radius | --radius-md | Outer corner radius. Preview with Border Radius Generator. |
| Shadow | --shadow-xs | Optional subtle shadow on the entire group container |
| Transition | --duration-fast (150ms) | Active/inactive state transitions |
Border Collapse Logic
The key to button group styling is border management:
/* Connected button group */
.btn-group > button { border-radius: 0; }
.btn-group > button:first-child { border-top-left-radius: var(--radius-md); border-bottom-left-radius: var(--radius-md); }
.btn-group > button:last-child { border-top-right-radius: var(--radius-md); border-bottom-right-radius: var(--radius-md); }
.btn-group > button + button { margin-left: -1px; } /* Collapse borders */
States
Button Group States
| State | Visual Treatment | Notes |
|---|---|---|
| Default | All buttons in their inactive/resting state | No button is selected (unless segmented with default value) |
| Hover | Individual button hover effect | Only the hovered button changes — others remain at rest |
| Active (Pressed) | Individual button active state | Only the clicked button shows press feedback |
| Selected | Filled background, contrasting text on the selected button | For segmented controls — clearly distinguishes the active option |
| Disabled | All buttons muted with no pointer interaction | The entire group is disabled; individual buttons should not be independently disabled within a group |
| Focus | Focus ring on the currently focused button | In segmented/radio mode, arrow keys move focus between buttons |
| Loading | Spinner on one or all buttons, group interaction blocked | Show loading on the specific button that triggered the action |
Segmented Control Selection States
| Configuration | Behavior | ARIA Pattern |
|---|---|---|
| Single (exclusive) | One button active at a time, always one selected | role="radiogroup" + role="radio" with aria-checked |
| Multiple | Any combination of buttons can be active | role="group" + `aria-pressed="true |
| None required | Selection can be deselected entirely | role="group" + aria-pressed toggles |
Accessibility
Accessibility
Button groups require careful ARIA implementation because they can function as either a group of independent actions or a selection control.
Role Assignment (WCAG 4.1.2 – Name, Role, Value):
- Toolbar pattern: Use
role="toolbar"witharia-labelon the container. Each button is a standard<button>. Arrow keys navigate between buttons, Tab moves to/from the toolbar. - Segmented control (single select): Use
role="radiogroup"on the container witharia-label. Each button getsrole="radio"witharia-checked="true|false". Arrow keys move selection. - Toggle buttons (multi select): Use
role="group"witharia-label. Each button usesaria-pressed="true|false". - Never mix patterns — pick one and apply consistently.
Keyboard Navigation (WCAG 2.1.1 – Keyboard):
- Toolbar/radiogroup: Arrow Left/Right (or Up/Down for vertical) moves focus between buttons. Home/End jump to first/last. The group is a single Tab stop.
- Toggle group: Each button is individually Tab-focusable (standard button behavior). No arrow key navigation needed.
- Focus must be visible on the active button — ensure the focus ring is not clipped by
overflow: hiddenon the group container.
Contrast (WCAG 1.4.3 – Contrast Minimum):
- Selected button text must achieve 4.5:1 against the selected background. Use the Contrast Checker.
- Unselected button text must achieve 4.5:1 against the unselected background.
- The visual boundary between selected and unselected buttons must be perceivable — don't rely solely on background color. Consider adding a border or weight change.
Non-Text Contrast (WCAG 1.4.11):
- The group's outer border (if present) must achieve 3:1 against the page background.
- Dividers between buttons must achieve 3:1 if they carry meaning (separating distinct sections).
Grouping (WCAG 1.3.1 – Info and Relationships):
- Always provide
aria-labeloraria-labelledbyon the group container describing the group's purpose: "Text formatting options", "View mode", "Pagination". - For segmented controls, the label must indicate what's being selected: "Display density" not "Buttons".
Usage Guidelines
Usage Guidelines
Do:
- Limit button groups to 2–5 buttons. More than 5 creates cognitive overload and layout issues on mobile.
- Use consistent button variants within a group — all outlined, or all ghost. Don't mix filled and outlined.
- Provide
aria-labeldescribing the group's purpose on the container element. - For segmented controls, pre-select a default value so the control is never in an ambiguous empty state.
- Use icon-only buttons with
aria-labelin toolbars. See Icon Button. - Collapse to a Dropdown Menu on small screens when the group has >3 buttons.
Don't:
- Don't use a button group for primary + secondary action pairs — they should be separate Buttons with hierarchy.
- Don't mix different sizes within a single button group.
- Don't put destructive actions inside a button group without visual distinction — at minimum, color the destructive button differently.
- Don't nest button groups inside other button groups.
- Don't use a segmented control as navigation — use Tabs instead. The distinction: tabs change content panels, segmented controls change how content is displayed (view mode, sort order, density).
Code Snippets
<!-- Connected button group -->
<div class="btn-group" role="group" aria-label="Text formatting">
<button class="btn btn--ghost" aria-pressed="true">Bold</button>
<button class="btn btn--ghost" aria-pressed="false">Italic</button>
<button class="btn btn--ghost" aria-pressed="false">Underline</button>
</div>
<!-- Segmented control (single selection) -->
<div class="btn-group btn-group--segmented" role="radiogroup" aria-label="View mode">
<button class="btn btn--active" role="radio" aria-checked="true">Grid</button>
<button class="btn" role="radio" aria-checked="false">List</button>
<button class="btn" role="radio" aria-checked="false">Map</button>
</div>
<!-- Vertical button group -->
<div class="btn-group btn-group--vertical" role="group" aria-label="Actions">
<button class="btn btn--secondary">Edit</button>
<button class="btn btn--secondary">Duplicate</button>
<button class="btn btn--destructive">Delete</button>
</div>
<style>
.btn-group {
display: inline-flex;
}
.btn-group > .btn {
border-radius: 0;
position: relative;
}
.btn-group > .btn + .btn {
margin-left: -1px;
}
.btn-group > .btn:first-child {
border-top-left-radius: var(--radius-md);
border-bottom-left-radius: var(--radius-md);
}
.btn-group > .btn:last-child {
border-top-right-radius: var(--radius-md);
border-bottom-right-radius: var(--radius-md);
}
.btn-group > .btn:focus {
z-index: 1;
}
.btn-group--segmented .btn--active {
background: var(--color-primary-600);
color: var(--color-on-primary);
border-color: var(--color-primary-600);
}
.btn-group--vertical {
flex-direction: column;
}
.btn-group--vertical > .btn {
border-radius: 0;
}
.btn-group--vertical > .btn + .btn {
margin-left: 0;
margin-top: -1px;
}
.btn-group--vertical > .btn:first-child {
border-top-left-radius: var(--radius-md);
border-top-right-radius: var(--radius-md);
}
.btn-group--vertical > .btn:last-child {
border-bottom-left-radius: var(--radius-md);
border-bottom-right-radius: var(--radius-md);
}
</style>import React, { useState, useCallback } from 'react';
import styles from './ButtonGroup.module.css';
import clsx from 'clsx';
interface ButtonGroupProps {
variant?: 'connected' | 'separated' | 'segmented';
size?: 'sm' | 'md' | 'lg';
orientation?: 'horizontal' | 'vertical';
value?: string | string[];
onChange?: (value: string) => void;
exclusive?: boolean;
disabled?: boolean;
fullWidth?: boolean;
'aria-label': string;
children: React.ReactNode;
}
export function ButtonGroup({
variant = 'connected',
size = 'md',
orientation = 'horizontal',
value,
onChange,
exclusive = true,
disabled = false,
fullWidth = false,
'aria-label': ariaLabel,
children,
}: ButtonGroupProps) {
const isSegmented = variant === 'segmented';
const role = isSegmented
? exclusive ? 'radiogroup' : 'group'
: 'group';
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
const buttons = Array.from(
(e.currentTarget as HTMLElement).querySelectorAll('button:not(:disabled)')
) as HTMLButtonElement[];
const current = buttons.indexOf(e.target as HTMLButtonElement);
if (current === -1) return;
let next = -1;
const isHorizontal = orientation === 'horizontal';
if ((isHorizontal && e.key === 'ArrowRight') || (!isHorizontal && e.key === 'ArrowDown')) {
next = (current + 1) % buttons.length;
} else if ((isHorizontal && e.key === 'ArrowLeft') || (!isHorizontal && e.key === 'ArrowUp')) {
next = (current - 1 + buttons.length) % buttons.length;
} else if (e.key === 'Home') {
next = 0;
} else if (e.key === 'End') {
next = buttons.length - 1;
}
if (next !== -1) {
e.preventDefault();
buttons[next].focus();
if (isSegmented && exclusive) {
buttons[next].click();
}
}
}, [orientation, exclusive, variant]);
return (
<div
className={clsx(styles.root, styles[variant], styles[orientation], styles[size], {
[styles.fullWidth]: fullWidth,
})}
role={role}
aria-label={ariaLabel}
onKeyDown={isSegmented ? handleKeyDown : undefined}
>
{React.Children.map(children, (child) => {
if (!React.isValidElement(child)) return child;
const isSelected = Array.isArray(value)
? value.includes(child.props.value)
: value === child.props.value;
return React.cloneElement(child as React.ReactElement<any>, {
className: clsx(child.props.className, {
[styles.active]: isSelected,
}),
disabled: disabled || child.props.disabled,
onClick: () => onChange?.(child.props.value),
...(isSegmented && exclusive
? { role: 'radio', 'aria-checked': isSelected }
: isSegmented
? { 'aria-pressed': isSelected }
: {}),
tabIndex: isSegmented && exclusive
? isSelected ? 0 : -1
: undefined,
});
})}
</div>
);
}Design Systems
Design System Implementations
Material Design (MUI) provides <ButtonGroup> with variant (contained, outlined, text), orientation (horizontal, vertical), size, and color props. For segmented controls, MUI uses <ToggleButtonGroup> with exclusive prop and <ToggleButton> children that support value and selected. MUI handles border collapse internally — middle buttons have border-radius: 0, and overlapping borders are managed via negative margins with z-index on hover/focus. The disableElevation prop removes box-shadow from contained variants.
Ant Design provides <Button.Group> which renders a <div> wrapping standard <Button> components. Ant uses the :not(:first-child):not(:last-child) CSS pattern to zero out internal radii. For segmented controls, Ant Design v5 introduced the <Segmented> component — a standalone component with options, value, onChange, and block (full-width) props. Segmented supports icons, labels, and custom renders per option.
Chakra UI offers <ButtonGroup> with isAttached (connected), spacing (separated), size, and variant props. Chakra passes size and variant to all child <Button> components via context. The isAttached prop triggers the border-radius and margin collapsing logic. Chakra does not include a segmented control — teams build one with useRadioGroup hook and styled radio buttons. Preview your border-radius values with the Border Radius Generator.
Bootstrap implements button groups via the .btn-group class with role="group". Bootstrap handles border-radius collapsing, z-index stacking, and border overlap through detailed CSS selectors. The .btn-group-vertical modifier creates vertical stacks. Bootstrap also provides .btn-toolbar for grouping multiple .btn-group elements with consistent spacing. For toggle behavior, Bootstrap's JavaScript plugin adds .active class and aria-pressed management.
Apple Human Interface Guidelines defines the Segmented Control as the primary multi-button pattern. In UIKit, UISegmentedControl supports text and image segments with a sliding selection indicator. SwiftUI's Picker with .pickerStyle(.segmented) creates the same control declaratively. Apple's segmented controls always enforce single selection and are limited to 5 segments. They should only change content presentation, never navigate. See the Button page for Apple's standard button guidance.
Tailwind CSS button groups are built with flexbox utilities: inline-flex on the container, rounded-none on middle children, rounded-l-md on first, rounded-r-md on last, and -ml-px for border collapse. Tailwind UI and Headless UI provide pre-built <RadioGroup> components for segmented control behavior with full keyboard and ARIA support. The utility-first approach gives maximum flexibility but requires manual implementation of state management and accessibility attributes.