Components / forms
ButtonGroup
Only the joined form ships. The gapped form was already data-se-layout="cluster", which landed earlier in the same batch, so building a component for it would have been a second name for one thing.
Summary
Actions that belong together, joined so they read as one control.
| APG pattern | none |
| Built on | native elements |
| Status | stable |
| Since | 0.2.0 |
<ButtonGroup label="Text alignment">
<Button intent="neutral" emphasis="outline" iconOnly={…} label="Align left" />
<Button intent="neutral" emphasis="outline" iconOnly={…} label="Align center" />
</ButtonGroup>
Anatomy
A <div data-se="button-group"> holding buttons as direct children. It has no parts of its own — the children are the anatomy.
_No named parts. The frame is the whole anatomy._
Properties
| Property | Values | Default | What it does |
|---|---|---|---|
label | string | none | Names the set and gives it role="group". Omitted, no role is written: a group with no name announces a boundary and says nothing about it, which costs a reader a stop and tells them nothing. |
children | React.ReactNode | none | The buttons. They are joined edge to edge, so mixing intents inside one group asks the reader to believe a destructive action and a neutral one are the same control. |
States
| State | Expressed as | |
|---|---|---|
| Focused | :focus-visible | |
| Hover | :hover |
Both belong to the children; the group itself has none.
Behavior
None. It joins edges.
Accessibility
label is what makes the group worth announcing. With one, the set carries role="group" and that name. Without one, no role is written at all — a group with no name announces a boundary and says nothing about it, which costs a reader a stop and tells them nothing.
Every button is its own tab stop. These are separate actions that happen to sit together. A set long enough that tabbing through it is tiresome wanted to be a Toolbar.
Every child keeps its own accessible name. An icon-only button in a group still needs its label — the group's name says what the set is for, not what each button does.
Keyboard
None of its own. Every child is a separate tab stop and keeps its own keys.
Roles, states and properties
| Attribute | Set by |
|---|---|
role | always group |
Focus
The group is not focusable; its children are. A set long enough that tabbing through it is tiresome wanted to be a Toolbar, which is one stop with arrows inside.
Screen reader
Not recorded as verified. Stage 4 of the readiness ladder — driving each component with VoiceOver and NVDA — is unstarted across the package, and no component should claim otherwise until docs/journal/ records the session that did it.
Contrast and target size
npm run check:contrast audits every color pair this component uses across all four skins, light and dark, and fails the build on a shortfall. Two waivers are on the record in docs/audit/DEFERRED.md.
It sets no target size of its own; anything interactive inside it brings its own.
Tested
test/browser/button-group.spec.mjs
Known gaps
_No upstream requirement is recorded as not applicable._
Appearance
| Token | Used for |
|---|---|
--se-border-width | margin-inline-start |
--se-layer-raised | z-index |
A hovered or focused child takes --se-layer-raised. The children overlap by one border, so without a layer the focus ring is drawn and then painted over on one side by the neighbor.
Motion
None of its own. The children keep theirs.
Content
The group's label names the set — Text alignment, View mode. It is heard once, before the buttons.
Usage
Three things it is not, and each is a merge someone will propose.
Not a row of buttons with a gap. That is data-se-layout="cluster" and needs no component. A dialog's Cancel and Publish are a cluster: they are alternatives, and joining them says they are one action in two flavors.
Not a Segmented control. That is a single-select input — one option is chosen and stays chosen — which makes it a RadioGroup in a different skin, with aria-checked and one tab stop.
Not a Toolbar. That is one tab stop with arrow keys inside.
Mixed emphases join cleanly and mixed intents do not. All three emphases draw the same border, so the seams stay even. Two intents in one group asks the reader to believe a destructive action and a neutral one are the same control.
Notes
Only the joined form ships. The gapped form was already data-se-layout="cluster", which landed earlier in the same batch, so building a component for it would have been a second name for one thing.