Primitives Actions
Button group
Buttons joined into one control: role="group" names the set, and the stylesheet joins the borders and corners. Each button keeps its own behavior and tab stop. Tier 0.
- Tier
- 0 · Native
- Behavior JS
- 0 B brotli · 0 B gzip ·
fixtures/· methodresults.json (native-only) - Platform features
role="group",logical radii,color-mix(),:focus-visible- Shared listeners
- none
- Per-instance listeners
- none
- Lazy state
- none
- Native base
buttons in role="group"
On this page
Example
Why this implementation exists
Related actions often read as one control: undo and redo, zoom in and out, a main action and its menu. Joining them is layout. The buttons stay native buttons, and role="group" with a name tells a screen reader that they belong together.
button-group.css pulls each button 1px over the previous one, squares the corners where two buttons meet, raises the hovered or focused button above its neighbours and, between filled buttons, draws the shared border in their own text color. It uses logical properties throughout, so the group mirrors under right-to-left text.
The browser owns
- every button: focus, the tab order, Enter and Space activation
- announcing the group’s name when focus enters it
- opening a split button’s menu popover from its trigger
Svelte Lean owns
- button-group.css: shared borders, square inner corners, dividers between filled buttons
- the horizontal and vertical orientations, mirrored under RTL
- the contract: a group is not a toolbar, and where a split button’s menu goes
- documentation
Usage
The markup needs no package. Install @svelte-lean/styles for the stylesheet; @svelte-lean/primitives adds the typed contract and nothing at runtime.
import '@svelte-lean/styles/button.css';
import '@svelte-lean/styles/button-group.css';Put the buttons directly inside the group, in reading order, with the variant and size each one needs; button.css draws them and button-group.css joins them, so load it after button.css (and toggle.css for toggles).
Name the group with aria-label or aria-labelledby. For a split button, place the menu popover after the group and give the trigger aria-haspopup="menu" and a name of its own.
Use a Toolbar instead when the set should be one tab stop with arrow keys between the controls, and a Toggle group when the buttons have a pressed state.
Anatomy
| Part | Element | data-slean-part | Required | Notes |
|---|---|---|---|---|
| root | <div role="group" aria-label="…" data-slean="button-group"> | – | yes | data-slean-orientation="horizontal|vertical". Named with aria-label or aria-labelledby. |
| buttons | <button data-slean="button">, <a href data-slean="button">, toggles | – | yes | The direct children, in reading order; each keeps its own variant, size and tab stop. |
| split button menu | <div popover role="menu" data-slean="menu"> | – | no | After the group, opened by a trigger with popovertarget and aria-haspopup="menu" as the last child. |
Runtime profile
Tier 0: the button group has no behavior module, the Vite plugin maps button-group to no module, and the page ships no Svelte Lean JavaScript for it. A split button's menu brings the menu behavior of its own contract.
Accessibility contract
role="group"witharia-labeloraria-labelledby: the name is announced when focus enters the group.- Every button is a native button in the tab order, activated by Enter and Space.
- A split button trigger has
aria-haspopup="menu"and its own name, such as “More publish options”; the menu is labelled by the trigger. - The focused button is raised above its neighbours, so the whole focus ring stays visible.
Keyboard
| Key | When | Result |
|---|---|---|
| Tab/Shift+Tab | anywhere | Moves from button to button (native); every button is a tab stop |
| Enter/Space | focus on a button | Activates it; on a split button trigger, opens the menu (native) |
Platform features
| Feature | Baseline | Outside the target |
|---|---|---|
Logical corner radii | Baseline 2021 | Not applicable |
color-mix() | Baseline 2023 | Two filled buttons meet without a divider |
popover, popovertarget (split button) | Baseline 2024 | The menu does not open; see the popover contract |
Without JavaScript
Fully functional: the buttons are native, and a split button's popover opens without JavaScript. The arrow keys inside its menu need the menu behavior.
Server rendering
Static markup; nothing is computed.
Before hydration
Nothing is attached; the group works before and after hydration alike.
Styling
button-group.css sets the flex row or column, the 1px overlap, the square inner corners, the stacking of the hovered and focused button and the dividers between filled buttons. The tokens it reads (the buttons read theirs from button.css):
The stylesheet button-group.css was not found in this build.
State selectors the stylesheet targets, all from the platform or ARIA: :disabled, :first-child, :focus-visible, :hover, :last-child, [aria-disabled="true"].
Variant attributes: data-slean-orientation (vertical); data-variant (soft, danger).
Compatibility notes
Logical corner radii are Baseline 2021 and color-mix() Baseline 2023, both widely available. Without color-mix(), two filled buttons meet without a divider.
Examples
Split button
The main action and a menu trigger in one group. The trigger opens a menu popover
with popovertarget; the popover sits after the group, so the trigger stays its last
child. Between two filled buttons the shared border is drawn in the buttons' text color.
<div role="group" aria-label="Publish" data-slean="button-group">
<button type="button" data-slean="button">Publish</button>
<button
type="button"
id="publish-more"
data-slean="button"
data-icon-only
popovertarget="publish-menu"
aria-haspopup="menu"
aria-label="More publish options"
>
<svg aria-hidden="true" viewBox="0 0 16 16" width="16" height="16">…</svg>
</button>
</div>
<!-- The menu goes after the group: the trigger stays the group's last child. -->
<div id="publish-menu" popover role="menu" aria-labelledby="publish-more" data-slean="menu">
<button type="button" role="menuitem">Schedule</button>
<button type="button" role="menuitem">Publish as draft</button>
</div>Vertical
data-slean-orientation="vertical" stacks the buttons in a column of one width and joins
them along the block axis. The keys do not change: every button is a tab stop.
<div
role="group"
aria-label="Zoom"
data-slean="button-group"
data-slean-orientation="vertical"
>
<button type="button" data-slean="button" data-variant="outline" data-size="sm">Zoom in</button>
<button type="button" data-slean="button" data-variant="outline" data-size="sm">Fit</button>
<button type="button" data-slean="button" data-variant="outline" data-size="sm">Zoom out</button>
</div>Testing
apps/playground/tests/primitives/display-extra.spec.tsPlaywright, with page JavaScript enabled and disabled: roles, names, keyboard, geometry, axepackages/styles/testsstatic checks of the stylesheet (layers, tokens, specificity, dark parity)
Source
packages/primitives/src/button-group/contract.mdthe contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behaviorpackages/primitives/src/button-group/contract.tstyped constants: name, tier, base, parts, options, eventspackages/styles/css/button-group.cssthe optional stylesheet