Skip to content

Button Group

Groups related buttons together to form a cohesive set of actions.

  • Button Bar
  • Toolbar

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:

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

VariantPurposeVisual Treatment
ConnectedDefault joined buttons with merged bordersNo gap, inner corners set to 0, outer corners rounded, shared borders collapse
SeparatedRelated but visually distinct buttonsSmall gap (2–4px) between buttons, each retains full border-radius
Segmented ControlMutually exclusive selection (acts like radio)Active button gets filled background, inactive buttons are ghost/outlined
ToolbarDense action strip for editor/formatting UIsIcon-only buttons, compact sizing, often includes dividers between groups
VerticalStacked buttons (mobile, sidebar actions)Column layout with horizontal borders collapsed

Size Variants

SizeButton HeightIcon SizeUse Case
Small32px16pxDense UIs, table row actions, secondary toolbars
Medium40px20pxStandard forms, content toolbars
Large48px24pxHero sections, mobile-first layouts

Shape Variants

Use the Border Radius Generator to preview these:

ShapeRadiusEffect
Rounded6–8px (outer corners only)Professional, enterprise aesthetic
Pill9999px (outer corners only)Friendly, modern, consumer-facing — creates a capsule shape
Square0pxBrutalist, editorial, or when button group touches adjacent elements

Properties

Button Group Properties

PropertyTypeDefaultDescription
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
valuestring | string[]—Selected value(s) for segmented control mode
onChange(value: string) => void—Callback when selection changes (segmented mode)
exclusivebooleantrueWhen true, only one button can be active (radio behavior). When false, multiple selections allowed (checkbox behavior).
disabledbooleanfalseDisables all buttons in the group
fullWidthbooleanfalseStretches the group to fill container width, distributing equally
childrenReactNode—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 CategoryToken ExampleButton Group Usage
Color – Active Fill--color-primary-600Selected/active button background in segmented mode
Color – Active Text--color-on-primarySelected button text color
Color – Inactive Filltransparent or --color-surface-primaryUnselected button background
Color – Inactive Text--color-text-primaryUnselected button text color
Color – Border--color-border-defaultShared border between buttons
Color – Divider--color-border-subtleVertical dividers between toolbar sections
Spacing – Gap0px (connected) / --space-1 (separated)Gap between buttons
Border – Width--border-width-default (1px)Button borders — collapsed on internal edges
Border – Radius--radius-mdOuter corner radius. Preview with Border Radius Generator.
Shadow--shadow-xsOptional 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

StateVisual TreatmentNotes
DefaultAll buttons in their inactive/resting stateNo button is selected (unless segmented with default value)
HoverIndividual button hover effectOnly the hovered button changes — others remain at rest
Active (Pressed)Individual button active stateOnly the clicked button shows press feedback
SelectedFilled background, contrasting text on the selected buttonFor segmented controls — clearly distinguishes the active option
DisabledAll buttons muted with no pointer interactionThe entire group is disabled; individual buttons should not be independently disabled within a group
FocusFocus ring on the currently focused buttonIn segmented/radio mode, arrow keys move focus between buttons
LoadingSpinner on one or all buttons, group interaction blockedShow loading on the specific button that triggered the action

Segmented Control Selection States

ConfigurationBehaviorARIA Pattern
Single (exclusive)One button active at a time, always one selectedrole="radiogroup" + role="radio" with aria-checked
MultipleAny combination of buttons can be activerole="group" + `aria-pressed="true
None requiredSelection can be deselected entirelyrole="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" with aria-label on 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 with aria-label. Each button gets role="radio" with aria-checked="true|false". Arrow keys move selection.
  • Toggle buttons (multi select): Use role="group" with aria-label. Each button uses aria-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: hidden on 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-label or aria-labelledby on 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-label describing 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-label in 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

html
<!-- 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>
tsx
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.

ActionsButton BarToolbar