Loading…
Loading…
Groups related buttons together to form a cohesive set of actions.
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:
When NOT to use a Button Group:
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.
| 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 | 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 |
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 |
| 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 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 |
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 */
| 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 |
| 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 |
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):
role="toolbar" with aria-label on the container. Each button is a standard <button>. Arrow keys navigate between buttons, Tab moves to/from the toolbar.role="radiogroup" on the container with aria-label. Each button gets role="radio" with aria-checked="true|false". Arrow keys move selection.role="group" with aria-label. Each button uses aria-pressed="true|false".Keyboard Navigation (WCAG 2.1.1 – Keyboard):
overflow: hidden on the group container.Contrast (WCAG 1.4.3 – Contrast Minimum):
Non-Text Contrast (WCAG 1.4.11):
Grouping (WCAG 1.3.1 – Info and Relationships):
aria-label or aria-labelledby on the group container describing the group's purpose: "Text formatting options", "View mode", "Pagination".Do:
aria-label describing the group's purpose on the container element.aria-label in toolbars. See Icon Button.Don't:
<!-- 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>
);
}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.