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.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
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.
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.
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.
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.
- 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.
- 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.
- Member height32 / 36 / 44px
Identical to a standalone button at the same size, so a group aligns with the inputs and selects beside it.
- 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.
- 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.
- 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.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used 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
| Token | Value | Used for |
|---|---|---|
| gap | Between members | |
| --space-3 | Minimum gap to the next control |
Radius
| Token | Value | Used for |
|---|---|---|
| --radius-md | Outer corners only |
Typography
| Token | Value | Used for |
|---|---|---|
| --text-label | Member labels |
Motion
| Token | Value | Used for |
|---|---|---|
| --duration-fast | Hover and press transitions |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Padding | Radius | Icon | Type | Touch target | When to use |
|---|---|---|---|---|---|---|---|
| Small | 32px | 0 10px | 6px outer | 15px | 12px | — | Table toolbars, card headers, anywhere beside a small input. |
| Medium | 36px | 0 14px | 8px outer | 16px | 13px | — | The default. Page-level controls and filter bars. |
| Large | 44px | 0 18px | 10px outer | 18px | 15px | — | Touch-first layouts and marketing surfaces. |
| Icon only | Matches size | Square | — | — | — | 44px on coarse pointers | Formatting bars. Every member still needs an accessible name. |
<div role="group" aria-label="Export format">Not a checklist to run at the end. These are the requirements the component was built from.
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
| Tab | Enters the group and stops on each member. A group is not a composite widget, so every button is its own tab stop. |
| Space / Enter | Activates 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 + Tab | Leaves 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.
| Attribute | Applied to | Notes |
|---|---|---|
| role="group" | The container | Plus aria-label naming what the members have in common. Without it the buttons announce as three unrelated controls. |
| aria-pressed | Each independent toggle | Only on toggles. A group of plain command buttons must not have it — it would claim a state that does not exist. |
| aria-label | Icon-only members | Required. A tooltip is not an accessible name; it is a supplement to one. |
| aria-disabled | A member that is temporarily unavailable | Prefer this to the disabled attribute when the reason is explainable, so the button stays focusable and can announce why. |
Example usage
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
<!-- 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
.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
| Prop | Type | Default | Description |
|---|---|---|---|
| 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. |
| className | string | — | Applied to the container. |
Segmented
| Prop | Type | Default | Description |
|---|---|---|---|
| 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. |
| fullWidth | boolean | false | Stretches members to equal widths across the container. |
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.