Skip to content

Button Group

Two to five related buttons rendered as one unit. The shared border is a claim that these actions belong together — if they do not, it is a lie the user has to work around.

Also called Joined Buttons, Segmented Button, Action Group — in this system all of them are Button Group.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground

Independent toggles vs. one value

Two controls that look almost identical. Alignment is one value, so it is a segmented control with radio semantics. Styling is three independent booleans, so it is a button group of aria-pressed toggles.

Deployment finished in 42 seconds across three regions.

Sizes

Every button in a group shares one size. Mixed sizes break the shared baseline and the joined border stops reading as a single object.

Icon-only groups

The densest form, and the one that most needs the joined border. Every button still needs an accessible name, and a tooltip is not one.

Where the ceiling is

Three is comfortable, five is the ceiling, seven is a toolbar that has not been designed. Past five the group stops reading as a set of alternatives and starts reading as an unlabelled menu.

Three
Seven

Interaction states

Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.

Default
One pressed
Filled
One disabled
Loading
Icon only
Two members
Large

Anatomy

Every part, every measurement, and the reason it is that number.

Three members sharing one border. The middle one is pressed, which is carried by fill and text colour rather than by a change in size.

  1. Shared border1px, collapsed between members

    Adjacent borders overlap rather than stack. Two abutting 1px borders read as a 2px seam, which makes the group look like it has been assembled badly.

  2. Corner radiusOuter only

    The first member keeps its left corners, the last keeps its right, and everything between is square. Rounded inner corners leave visible notches at the seams.

  3. Member height32 / 36 / 44px

    Identical to a standalone button at the same size, so a group aligns with the inputs and selects beside it.

  4. Gap0px inside, 12px outside

    Zero within the group is what makes it one object. The gap to the next control must be at least 12px or the boundary of the group disappears.

  5. Pressed stateTonal fill + accent text

    Fill and text change together. A group where the pressed member also changes size or weight reflows the whole row on every press.

  6. Focus ring2px, offset 2px, above siblings

    The focused member is raised in z-order so its ring is not clipped by the neighbour’s border. This is the detail everyone forgets and it looks broken immediately.

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed for
--ds-border—The shared outline
--ds-surface—Unpressed fill
--ds-layer-hover—Hover on a member
--ds-accent-subtle—Pressed fill
--ds-accent-text—Pressed label
--ds-focus-ring—Focus outline on the active member

Spacing

TokenValueUsed for
gapBetween members
--space-3Minimum gap to the next control

Radius

TokenValueUsed for
--radius-mdOuter corners only

Typography

TokenValueUsed for
--text-labelMember labels

Motion

TokenValueUsed for
--duration-fastHover and press transitions

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightPaddingRadiusIconTypeTouch targetWhen to use
Small32px0 10px6px outer15px12px—Table toolbars, card headers, anywhere beside a small input.
Medium36px0 14px8px outer16px13px—The default. Page-level controls and filter bars.
Large44px0 18px10px outer18px15px—Touch-first layouts and marketing surfaces.
Icon onlyMatches sizeSquare———44px on coarse pointersFormatting bars. Every member still needs an accessible name.

Do

Keep every member the same size and variantThe group is one object. A single filled member among outlined ones creates a primary action, and a group with a primary action should have been a split button.
<div role="group" aria-label="Export format">
Label the group, not just the buttons<code>aria-label="Export format"</code> on the container turns three unrelated announcements into one coherent set. Without it a screen-reader user hears "CSV, button" with no idea what CSV applies to.
Raise the focused member above its neighboursA focus ring drawn under the adjacent border is clipped on one side and reads as a rendering bug. One line of z-index fixes it permanently.
Use pressed state, not selected, for independent toggles<code>aria-pressed</code> says "this is on". <code>aria-selected</code> says "this is the chosen one of several", which is a different and usually wrong promise.

Don't

Do not join unrelated actionsThe shared border claims these belong together. Joining Save to Delete removes the gap that stops a mis-click and puts a destructive action inside a routine group.
Do not give one member emphasisA filled member inside an outlined group is a primary action, and the joined border then tells the user the alternatives are the same weight as the recommendation. That is a split button.
Do not exceed five membersPast five the group stops reading as a set of alternatives. Hick’s law applies: six equally weighted, equally styled options with no headings is a menu without a label.
Do not use a group where only one can be activeExclusive choice is a value, and values are radios. Announcing "Left, toggle button, pressed" instead of "Left, radio button, selected, 1 of 3" hides the exclusivity from anyone not looking at the screen.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

1.3.1Info and RelationshipsA1.4.11Non-text ContrastAA2.1.1KeyboardA4.1.2Name, Role, ValueA

Contrast

  • The shared border must reach 3:1 against the surface behind it — it is the only thing showing where one target ends and the next begins.
  • The pressed member changes fill and text colour together, so the state survives greyscale and Windows High Contrast Mode.
  • The seam between two members must stay visible on hover. If the hover wash covers the divider the group momentarily reads as one wide button.

Keyboard

TabEnters the group and stops on each member. A group is not a composite widget, so every button is its own tab stop.
Space / EnterActivates or toggles the focused member.
← / →Only in a segmented control, where the group is a radiogroup. In a plain button group arrows do nothing, and adding them surprises people.
Shift + TabLeaves the group backwards, member by member.

Screen readers

  • A labelled group announces as "Export format, group" then "CSV, button, 1 of 3".
  • A toggle inside the group announces "Bold, toggle button, pressed" — the word "pressed" is the entire payload of aria-pressed.
  • Never rely on the visual seam to communicate grouping. It is invisible to assistive tech; role and label are what carry it.

Focus & touch

  • The focused member must be raised above its siblings so the ring is drawn complete on all four sides. Focus order follows DOM order, which must follow visual order — never reorder a group with CSS.
  • Members share edges, so a mis-tap lands on a neighbour rather than on nothing. Keep icon-only members at 44px on coarse pointers, and prefer two or three members on touch — a five-member group on a phone is five 60px targets in a row and the middle three are hard to hit accurately.
AttributeApplied toNotes
role="group"The containerPlus aria-label naming what the members have in common. Without it the buttons announce as three unrelated controls.
aria-pressedEach independent toggleOnly on toggles. A group of plain command buttons must not have it — it would claim a state that does not exist.
aria-labelIcon-only membersRequired. A tooltip is not an accessible name; it is a supplement to one.
aria-disabledA member that is temporarily unavailablePrefer this to the disabled attribute when the reason is explainable, so the button stays focusable and can announce why.

Code

Example usage

tsx
1import { Button, ButtonGroup, IconButton } from '@/ui/Button'2import { Segmented } from '@/ui/Toggle'34// Independent toggles: role="group", each member owns aria-pressed5<ButtonGroup aria-label="Text style">6  {MARKS.map((m) => (7    <IconButton8      key={m.id}9      variant={active.includes(m.id) ? 'tonal' : 'outlined'}10      aria-pressed={active.includes(m.id)}11      onClick={() => toggle(m.id)}12      label={m.label}          // required — the icon is not a name13      icon={m.icon}14    />15  ))}16</ButtonGroup>1718// Exclusive choice: this is a value, so it is a radiogroup19<Segmented20  aria-label="Text alignment"21  value={align}22  onChange={setAlign}23  options={[24    { value: 'left', label: <AlignLeft size={15} /> },25    { value: 'center', label: <AlignCenter size={15} /> },26    { value: 'right', label: <AlignRight size={15} /> },27  ]}28/>

Framework-free HTML

html
<!-- Independent toggles -->
<div role="group" aria-label="Text style" class="ds-btn-group">
  <button type="button" class="ds-btn" aria-pressed="true" aria-label="Bold">
    <svg aria-hidden="true">…</svg>
  </button>
  <button type="button" class="ds-btn" aria-pressed="false" aria-label="Italic">
    <svg aria-hidden="true">…</svg>
  </button>
</div>

<!-- Exclusive choice is a radiogroup, not a group -->
<div role="radiogroup" aria-label="Text alignment" class="ds-btn-group">
  <button type="button" role="radio" aria-checked="true"  tabindex="0">Left</button>
  <button type="button" role="radio" aria-checked="false" tabindex="-1">Centre</button>
  <button type="button" role="radio" aria-checked="false" tabindex="-1">Right</button>
</div>

CSS

css
.ds-btn-group {
  display: inline-flex;
  isolation: isolate;              /* contains the z-index bump below */
}

/* Collapse the seam: two abutting 1px borders read as a 2px join. */
.ds-btn-group > * + * {
  margin-inline-start: -1px;
}

/* Outer corners only. Rounded inner corners leave notches at the seams. */
.ds-btn-group > *:not(:first-child):not(:last-child) {
  border-radius: 0;
}
.ds-btn-group > *:first-child {
  border-start-end-radius: 0;
  border-end-end-radius: 0;
}
.ds-btn-group > *:last-child {
  border-start-start-radius: 0;
  border-end-start-radius: 0;
}

/* The detail everyone forgets: without this the focus ring and the hover
   border are clipped by the next member and the group looks broken. */
.ds-btn-group > *:hover,
.ds-btn-group > *:focus-visible,
.ds-btn-group > *[aria-pressed='true'] {
  z-index: 1;
}

.ds-btn-group + * {
  margin-inline-start: var(--space-3);   /* 12px, or the group loses its edge */
}

Component API

ButtonGroup

PropTypeDefaultDescription
aria-label*string—Names what the members have in common. Without it the group is three unrelated buttons.
children*ReactNode—Two to five Button or IconButton elements, all the same size and variant.
classNamestring—Applied to the container.

Segmented

PropTypeDefaultDescription
value*T—The single selected value. Renders as a radiogroup.
onChange*(v: T) => void—Fired on click and on arrow-key movement.
options*{ value: T; label: ReactNode }[]—Two to five options. Past five, use a Select.
size'sm' | 'md''md'Matches the button scale.
fullWidthbooleanfalseStretches members to equal widths across the container.

Notes

Professional tips

  • Order members by frequency, not alphabetically, and never reorder them at runtime. A group whose buttons move is a group nobody can build muscle memory for.
  • Give members equal widths when the labels are close in length. Ragged widths in a three-member group look like a rendering accident rather than a design.
  • If one member is used ten times more than the others, that is the signal to convert the group into a split button.
  • When the group controls a view, echo the current state somewhere in the content — a pressed button in the corner is easy to miss on a full screen.

Performance

  • Do not animate the width of a member on press. In a joined group every neighbour reflows, and the seam visibly jitters.
  • For toggles, keep the pressed state in one piece of state and derive each member from it. Per-button state drifts the moment someone adds a "clear all".
  • Icon-only groups are the one place where rendering an SVG per member per row of a table becomes measurable — hoist the icons out of the map.

Common mistakes

  • Using role="group" for exclusive choice, so screen readers never learn that only one option can be active.
  • Forgetting the negative margin, leaving a 2px double border between every member.
  • Rounding every member’s corners, which leaves a visible notch at each seam.
  • Omitting the container label, so "CSV, button" is announced with no indication of what it applies to.
  • Letting the focus ring be clipped by the adjacent border because the focused member was not raised.
  • Putting a destructive action in a group with routine ones, removing the spacing that would otherwise prevent the mis-click.

Real-world recommendations

  • Two-member groups are the most reliable: Approve / Reject, Yes / No, Accept / Decline. The user reads both options in one fixation and the shared border makes the pairing unmissable.
  • On mobile, three members is usually the practical maximum for text labels. Beyond that either the labels truncate or the group scrolls, and both are worse than a select.
  • Instrument which member gets pressed. In most date-range groups one option accounts for 70% of use — that one should be the default, and the rest can often move into a menu.
  • In a table toolbar, a button group beside a plain button reads as "these three are one decision, that one is separate". That contrast is worth more than the density it buys.