# Button group

> Buttons joined into one control with shared borders, in a role="group".

Page: https://svelte-lean.vercel.app/primitives/button-group · Markdown: https://svelte-lean.vercel.app/primitives/button-group.md · Index: https://svelte-lean.vercel.app/llms.txt

Related buttons joined into one control, each with its own tab stop. Use it for sets like undo and redo or a split button.

```svelte
<script lang="ts">
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/button-group.css';
</script>

<div role="group" aria-label="History" data-slean="button-group">
	<button type="button" data-slean="button" data-variant="outlined" data-color="neutral">Undo</button>
	<button type="button" data-slean="button" data-variant="outlined" data-color="neutral">Redo</button>
	<button type="button" data-slean="button" data-variant="outlined" data-color="neutral">Restore</button>
</div>
```

The corners are logical: turn on RTL to see the rounded ends move.

## Examples

### Split button

```svelte
<script lang="ts">
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/button-group.css';
	import '@svelte-lean/styles/popover.css';
	import '@svelte-lean/styles/menu.css';
</script>

<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">
			<path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
		</svg>
	</button>
</div>
<!-- The menu goes after the group, so 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>
```

Between two filled buttons the shared border is drawn in their text color.

### Icon buttons

```svelte
<script lang="ts">
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/button-group.css';

	let zoom = $state(100);
</script>

<div role="group" aria-label="Zoom" data-slean="button-group">
	<button type="button" data-slean="button" data-variant="outlined" data-color="neutral" data-icon-only aria-label="Zoom out"
		disabled={zoom <= 50} onclick={() => (zoom -= 25)}>
		<svg aria-hidden="true" viewBox="0 0 16 16" width="16" height="16">
			<path d="M3.5 8h9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
		</svg>
	</button>
	<button type="button" data-slean="button" data-variant="outlined" data-color="neutral" aria-label="Reset zoom, now {zoom}%"
		onclick={() => (zoom = 100)}>
		{zoom}%
	</button>
	<button type="button" data-slean="button" data-variant="outlined" data-color="neutral" data-icon-only aria-label="Zoom in"
		disabled={zoom >= 200} onclick={() => (zoom += 25)}>
		<svg aria-hidden="true" viewBox="0 0 16 16" width="16" height="16">
			<path d="M3.5 8h9M8 3.5v9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
		</svg>
	</button>
</div>
```

Icon-only buttons take an aria-label; the middle one resets.

### Sizes

```svelte
<script lang="ts">
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/button-group.css';
</script>

<div role="group" aria-label="View, small" data-slean="button-group">
	<button type="button" data-slean="button" data-variant="outlined" data-color="neutral" data-size="small">Day</button>
	<button type="button" data-slean="button" data-variant="outlined" data-color="neutral" data-size="small">Week</button>
	<button type="button" data-slean="button" data-variant="outlined" data-color="neutral" data-size="small">Month</button>
</div>
<div role="group" aria-label="View, large" data-slean="button-group">
	<button type="button" data-slean="button" data-variant="outlined" data-color="neutral" data-size="large">Day</button>
	<button type="button" data-slean="button" data-variant="outlined" data-color="neutral" data-size="large">Week</button>
	<button type="button" data-slean="button" data-variant="outlined" data-color="neutral" data-size="large">Month</button>
</div>
```

Size and variant are set on each button.

### Vertical

```svelte
<script lang="ts">
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/button-group.css';
</script>

<div role="group" aria-label="Arrange" data-slean="button-group" data-slean-orientation="vertical">
	<button type="button" data-slean="button" data-variant="outlined" data-color="neutral" data-size="small">Bring to front</button>
	<button type="button" data-slean="button" data-variant="outlined" data-color="neutral" data-size="small">Bring forward</button>
	<button type="button" data-slean="button" data-variant="outlined" data-color="neutral" data-size="small">Send backward</button>
	<button type="button" data-slean="button" data-variant="outlined" data-color="neutral" data-size="small">Send to back</button>
</div>
```

data-slean-orientation="vertical" joins the buttons along the block axis; every button stays a tab stop.

## Usage

Plain markup with a stylesheet: install `@svelte-lean/styles`. No JavaScript runs.

```bash
npm install @svelte-lean/styles
```

`stylesheets`

```ts
import '@svelte-lean/styles/button.css';
import '@svelte-lean/styles/button-group.css';
```

Put the buttons directly inside the group, in reading order, and name it with `aria-label` or `aria-labelledby`. Load `button-group.css` after `button.css`. 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 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. |

## 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) |

## 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` (`contained`).

## Under the hood

Runtime and size

- **Tier**: 0 · Native
- **Behavior JS**: 0 B brotli · 0 B gzip · `fixtures/results.json (native-only)` · [method](https://svelte-lean.vercel.app/performance/methodology.md)
- **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"`

Tier 0: 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.

Why this implementation

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

Accessibility

- `role="group"` with `aria-label` or `aria-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.

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 and hydration

Static markup; nothing is computed.

Nothing is attached; the group works before and after hydration alike.

Compatibility

Logical corner radii are Baseline 2021 and `color-mix()` Baseline 2023, both widely available. Without `color-mix()`, two filled buttons meet without a divider.

Tests and source

- `apps/playground/tests/primitives/display-extra.spec.ts` Playwright, with page JavaScript enabled and disabled: roles, names, keyboard, geometry, axe
- `packages/styles/tests` static checks of the stylesheet (layers, tokens, specificity, dark parity)

- `packages/primitives/src/button-group/contract.md` the contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behavior
- `packages/primitives/src/button-group/contract.ts` typed constants: name, tier, base, parts, options, events
- `packages/styles/css/button-group.css` the optional stylesheet
