# Toggle group

> Buttons with aria-pressed in a group: single or multiple selection and roving focus, state in the attributes.

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

Toggle buttons in one named group with a single tab stop and arrow keys, for a formatting bar or a view switch. Tier 1.

```svelte
<script lang="ts">
	// With @svelte-lean/vite the register import is injected for the static
	// data-slean="toggle-group" marker.
	import '@svelte-lean/styles/toggle-group.css';
</script>

<div
	data-slean="toggle-group"
	role="group"
	aria-label="Text alignment"
	data-slean-required
>
	<button
		type="button"
		data-slean-part="item"
		data-slean-value="left"
		aria-pressed="true"
		tabindex="0"
	>
		Left
	</button>
	<button
		type="button"
		data-slean-part="item"
		data-slean-value="center"
		aria-pressed="false"
		tabindex="-1"
	>
		Center
	</button>
	<button
		type="button"
		data-slean-part="item"
		data-slean-value="right"
		aria-pressed="false"
		tabindex="-1"
	>
		Right
	</button>
</div>
```

Arrow keys move focus without pressing; Space or Enter presses. Pressing an item releases the others unless the group has data-slean-multiple.

## Examples

### Multiple

```svelte
<script lang="ts">
	import { on } from 'svelte/events';
	import type { ToggleGroupChangeDetail } from '@svelte-lean/primitives/toggle-group';

	let value = $state(['bold']);
	const follow = (node: HTMLElement) =>
		on(node, 'slean:change', (event) => {
			value = (event as CustomEvent<ToggleGroupChangeDetail>).detail.value;
		});
</script>

<div data-slean="toggle-group" role="group" aria-label="Text style" data-slean-multiple
	{@attach follow}>
	<button type="button" data-slean-part="item" data-slean-value="bold" aria-pressed="true"
		tabindex="0" aria-label="Bold">
		<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
				stroke-linecap="round" stroke-linejoin="round"><path d="M6 12h8a4 4 0 0 0 0-8H6v16h9a4 4 0 0 0 0-8" /></svg>
	</button>
	<button type="button" data-slean-part="item" data-slean-value="italic" aria-pressed="false"
		tabindex="-1" aria-label="Italic">
		<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
				stroke-linecap="round" stroke-linejoin="round"><path d="M19 4h-9M14 20H5M15 4 9 20" /></svg>
	</button>
	<button type="button" data-slean-part="item" data-slean-value="underline" aria-pressed="false"
		tabindex="-1" aria-label="Underline">
		<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
				stroke-linecap="round" stroke-linejoin="round"><path d="M6 4v6a6 6 0 0 0 12 0V4M4 20h16" /></svg>
	</button>
</div>
<p>Value: {value.join(', ') || 'none'}</p>
```

With data-slean-multiple every item is pressed on its own; slean:change reports the whole list.

### Bound to content

```svelte
<script lang="ts">
	import { on } from 'svelte/events';
	import type { ToggleGroupChangeDetail } from '@svelte-lean/primitives/toggle-group';

	const options = [
		{ value: 'left', label: 'Left' },
		{ value: 'center', label: 'Center' },
		{ value: 'right', label: 'Right' },
		{ value: 'justify', label: 'Justify' }
	];
	let align = $state('left');
	const follow = (node: HTMLElement) =>
		on(node, 'slean:change', (event) => {
			align = (event as CustomEvent<ToggleGroupChangeDetail>).detail.value[0] ?? align;
		});
</script>

<div data-slean="toggle-group" role="group" aria-label="Paragraph alignment"
	data-slean-required data-size="small" {@attach follow}>
	{#each options as option, i (option.value)}
		<button type="button" data-slean-part="item" data-slean-value={option.value}
			aria-pressed={i === 0} tabindex={i === 0 ? 0 : -1}>
			{option.label}
		</button>
	{/each}
</div>
<p style:text-align={align}>
	The group writes aria-pressed; the application reads the new value from slean:change and
	aligns this paragraph. Nothing else holds the state.
</p>
```

Single and required: one item stays pressed.

### Form value

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

	let status = $state('open');
</script>

<!-- The hidden native part gives the group a form value and bind:value. -->
<div data-slean="toggle-group" data-slean-multiple role="group" aria-label="Status filter">
	<input type="hidden" name="status" bind:value={status} data-slean-part="native" />
	<button
		type="button"
		data-slean-part="item"
		data-slean-value="open"
		aria-pressed="true"
		tabindex="0"
	>
		Open
	</button>
	<button
		type="button"
		data-slean-part="item"
		data-slean-value="pending"
		aria-pressed="false"
		tabindex="-1"
	>
		Pending
	</button>
	<button
		type="button"
		data-slean-part="item"
		data-slean-value="closed"
		aria-pressed="false"
		tabindex="-1"
	>
		Closed
	</button>
</div>
<p>Form value: <code>status={status}</code></p>
```

The behavior writes the pressed values into the hidden input and dispatches input and change; the form submits them and bind:value follows.

### Vertical

```svelte
<div
	data-slean="toggle-group"
	role="group"
	aria-label="View"
	data-slean-required
	data-slean-orientation="vertical"
>
	<button
		type="button"
		data-slean-part="item"
		data-slean-value="list"
		aria-pressed="true"
		tabindex="0"
	>
		List
	</button>
	<button
		type="button"
		data-slean-part="item"
		data-slean-value="grid"
		aria-pressed="false"
		tabindex="-1"
	>
		Grid
	</button>
	<button
		type="button"
		data-slean-part="item"
		data-slean-value="board"
		aria-pressed="false"
		tabindex="-1"
		disabled
	>
		Board
	</button>
</div>
```

ArrowDown and ArrowUp move between the items; a disabled item is skipped.

### Looks

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

	const variants = ['contained', 'outlined', 'text'];
	const colors = ['primary', 'secondary', 'success'];
</script>

{#each variants as variant}
	<div class="row">
		{#each colors as color}
			<div data-slean="toggle-group" role="group" aria-label="View, {variant} {color}"
				data-slean-required data-variant={variant} data-color={color}>
				<button type="button" data-slean-part="item" data-slean-value="list" aria-pressed="true" tabindex="0">List</button>
				<button type="button" data-slean-part="item" data-slean-value="grid" aria-pressed="false" tabindex="-1">Grid</button>
			</div>
		{/each}
	</div>
{/each}
```

contained and outlined share one track; text has none. The color draws the pressed item.

### Sizes

```svelte
<div data-slean="toggle-group" role="group" aria-label="Density, small"
	data-slean-required data-size="small">
	<button
		type="button"
		data-slean-part="item"
		data-slean-value="compact"
		aria-pressed="true"
		tabindex="0"
	>
		Compact
	</button>
	<button
		type="button"
		data-slean-part="item"
		data-slean-value="comfortable"
		aria-pressed="false"
		tabindex="-1"
	>
		Comfortable
	</button>
</div>
<div data-slean="toggle-group" role="group" aria-label="Density, large"
	data-slean-required data-size="large">
	<button
		type="button"
		data-slean-part="item"
		data-slean-value="compact"
		aria-pressed="true"
		tabindex="0"
	>
		Compact
	</button>
	<button
		type="button"
		data-slean-part="item"
		data-slean-value="comfortable"
		aria-pressed="false"
		tabindex="-1"
	>
		Comfortable
	</button>
</div>
```

data-size on the root.

## Usage

Install `@svelte-lean/primitives` and `@svelte-lean/styles`. With `@svelte-lean/vite` the behavior registers itself for every `data-slean="toggle-group"`; without it, import the register module once.

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

`+layout.svelte or any client module`

```ts
import '@svelte-lean/primitives/toggle-group/register';
```

`stylesheets`

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

Render every item with `aria-pressed`, a unique `data-slean-value` and the roving `tabindex` (`0` on the first pressed item, `-1` on the others), and name the group. The choice is exclusive by default; `data-slean-multiple` lets every item be pressed on its own, and `data-slean-required` keeps one item pressed. `slean:change` reports `detail.value` and `detail.previous` as lists in document order.

Inside a Toolbar the group handles no arrow key and writes no `tabindex`, so the toolbar stays one tab stop. A single choice that is a form value is Segmented or a Radio group: native radios that work without JavaScript.

`alignment.svelte`

```svelte
<script lang="ts">
	import {
		setToggleGroupValue,
		toggleGroupValue,
		type ToggleGroupChangeDetail
	} from '@svelte-lean/primitives/toggle-group';

	let group: HTMLElement;

	$effect(() => {
		// slean:change is dispatched from the root before aria-pressed is written; value and
		// previous are lists of data-slean-value in document order. preventDefault() keeps them.
		const onchange = (event: Event) => {
			const { value, previous } = (event as CustomEvent<ToggleGroupChangeDetail>).detail;
			console.log(previous, '->', value);
		};
		group.addEventListener('slean:change', onchange);
		return () => group.removeEventListener('slean:change', onchange);
	});

	// The value is read from aria-pressed each time; the root keeps no copy.
	const current = () => toggleGroupValue(group);
	// Writes through the same cancelable event; false when a listener cancelled it.
	const reset = () => setToggleGroupValue(group, ['left']);
</script>

<div data-slean="toggle-group" role="group" aria-label="Text alignment" bind:this={group}>…</div>
```

`every option`

```html
<div
	data-slean="toggle-group"
	role="group"
	aria-label="View"
	data-slean-required
	data-slean-orientation="vertical"
	data-slean-loop="false"
	dir="rtl"
>
```

## Anatomy

| Part | Element | data-slean-part | Required | Notes |
| --- | --- | --- | --- | --- |
| root | `<div data-slean="toggle-group" role="group" aria-label="…">` | – | yes | Options: data-slean-multiple (presence; single by default), -required, -orientation (horizontal, vertical), -loop. The value is read from the items; the root holds no copy. |
| item | `<button type="button" aria-pressed="false" data-slean-value="…">` | `item` | yes | Unique data-slean-value. tabindex="0" on one item, "-1" on the others, except inside a toolbar. |
| native | `<input type="hidden" name="…">` | `native` | no | Receives the pressed values, comma separated, with input and change after every change: a form value and bind:value. |

## Keyboard

| Key | When | Result |
| --- | --- | --- |
| `Tab`/`Shift+Tab` | entering or leaving | Enters on the tabindex="0" item, then leaves the group (native) |
| `Enter`/`Space` | focus on an item | Presses or releases it (native activation) |
| `ArrowRight`/`ArrowLeft` | horizontal | Next or previous enabled item, without pressing; swapped under dir="rtl" |
| `ArrowDown`/`ArrowUp` | vertical | Next or previous enabled item, without pressing |
| `Home`/`End` | focus on an item | First or last enabled item |

## Styling

`toggle-group.css` draws one bordered track around the items, fills the pressed items with the current color (`data-color`) in the `contained` variant (or a soft tint with `outlined`, no track with `text`), sizes them with `data-size`, stacks them in the vertical orientation and uses the system highlight colors in forced colors. The tokens it reads:

| Token | Default (light) | Applies to |
| --- | --- | --- |
| `--slean-space-1` | `calc(var(--slean-spacing) * 1)` | padding, min-block-size, border-radius |
| `--slean-border` | `var(--slean-neutral-6)` | border |
| `--slean-radius-md` | `var(--slean-radius)` | border-radius |
| `--slean-surface` | `oklch(100% 0 0)` | background |
| `--slean-fg` | `var(--slean-neutral-12)` | color |
| `--slean-space-2` | `calc(var(--slean-spacing) * 2)` | gap, padding-inline |
| `--slean-control-height-md` | `2.25rem` | min-block-size |
| `--slean-space-3` | `calc(var(--slean-spacing) * 3)` | padding-inline |
| `--slean-fg-muted` | `var(--slean-neutral-11)` | color |
| `--slean-text-sm` | `0.875rem` | font-size |
| `--slean-font-weight-medium` | `500` | font-weight |
| `--slean-duration-fast` | `100ms` | transition |
| `--slean-ease` | `cubic-bezier(0.2, 0, 0, 1)` | transition |
| `--slean-control-height-sm` | `2rem` | min-block-size |
| `--slean-control-height-lg` | `2.75rem` | min-block-size |
| `--slean-space-4` | `calc(var(--slean-spacing) * 4)` | padding-inline |
| `--slean-text-md` | `1rem` | font-size |
| `--slean-muted` | `var(--slean-neutral-3)` | background |
| `--slean-color-main` | `var(--slean-primary-main)` | border-color, background |
| `--slean-color-contrast-text` | `var(--slean-primary-contrast-text)` | color |
| `--slean-color-soft` | `var(--slean-primary-soft)` | background |
| `--slean-color-soft-text` | `var(--slean-primary-soft-text)` | color |
| `--slean-color-dark` | `var(--slean-primary-dark)` | background |

State selectors the stylesheet targets, all from the platform or ARIA: `:disabled`, `:hover`, `[aria-disabled="true"]`, `[aria-pressed="true"]`.

Variant attributes: `data-variant` (`text`, `outlined`); `data-slean-orientation` (`vertical`); `data-size` (`small`, `large`).

## Under the hood

Runtime and size

- **Tier**: 1 · Delegated micro-behavior
- **Behavior JS**: 1932 B brotli · 2168 B gzip · `packages/primitives/artifacts/size.json` · [method](https://svelte-lean.vercel.app/performance/methodology.md)
- **Platform features**: `<button>`, `aria-pressed`, `role="group"`, `:dir(rtl)`
- **Shared listeners**: click, keydown
- **Per-instance listeners**: none
- **Lazy state**: none
- **Native base**: `<button aria-pressed> items in role="group"`

The group registers one `click` and one `keydown` handler with the shared router. A thousand groups keep one listener per type (`tests/toggle-group.test.ts`). Its bytes are the production registration with the core kernel.

Why this implementation

A row of toggle buttons that act together needs more than each button’s own state: one tab stop for the row, arrow keys between the items, and, by default, the rule that pressing one releases the others. The platform has none of this for buttons.

The pressed states stay in `aria-pressed`, which is what screen readers announce, and the tab stop in `tabindex`. The group keeps no copy of its value: `toggleGroupValue()` reads the pressed items, so the attributes and the value cannot disagree.

### The browser owns

- the buttons: focus, Enter and Space activation, the accessible names
- announcing the group name and each pressed state (screen readers)
- the click and keydown events routed to the behavior

### Svelte Lean owns

- pressing and releasing items, the single rule and data-slean-required
- the roving tabindex, arrow keys, Home and End, RTL
- leaving focus movement to a surrounding toolbar
- the cancelable slean:change with the value list; toggleGroupValue() and setToggleGroupValue()
- development validation, toggle-group.css

Accessibility

- `role="group"` with a name, so the group is announced when focus enters it; each item is a native `<button>` with `aria-pressed`.
- One item is in the tab order; the arrow keys, Home and End move it. Enter and Space press or release the focused item through the native button activation.
- Item names do not change with the state. `aria-disabled="true"` keeps an item focusable and skipped by the arrows; `disabled` removes it from focus.
- The single rule does not turn the items into radios: they stay toggle buttons, each announced as pressed or not pressed.
- `role="group"` has no `aria-orientation`; the vertical orientation lives in the layout and the keys.

Platform features

| Feature | Baseline | Outside the target |
| --- | --- | --- |
| `<button> activation` | Widely available | Not applicable |
| `aria-pressed, role="group"` | ARIA 1.2, announced by screen readers | Not applicable |
| `:dir()` | Baseline 2023 | dir="auto" resolves to left-to-right; explicit dir attributes work |

Without JavaScript

The authored states are shown and announced and the authored tab stop is kept, but pressing changes nothing. A choice that must be made without JavaScript is a Radio group or Segmented control (native radios) or a set of checkboxes.

Server rendering and hydration

Render `aria-pressed` and the roving `tabindex` on the server. No ids are generated, and the register module is safe to import on the server.

Before the behavior loads, a click does nothing and the arrow keys scroll. The registration attaches no per-group state, so the first interaction after hydration works on the server’s markup directly.

Compatibility

Buttons, `aria-pressed` and `role="group"` work in every browser and screen reader in use. `dir="auto"` needs `:dir()` (Baseline 2023) to resolve; explicit `dir` attributes work everywhere. In Safari and in Firefox on macOS a click does not focus a button; the group focuses the clicked item itself.

Tests and source

- `packages/primitives/tests/toggle-group.test.ts` Vitest: single, multiple, required, keyboard, RTL, nested groups, inside a toolbar, 1000 groups
- `apps/playground/tests/primitives/toggles.spec.ts` Playwright: toggle, toggle group and toolbar with real focus and keys, and an axe scan
- `packages/styles/tests` static checks of the stylesheet (layers, tokens, specificity, dark parity)

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