Primitives Actions
Toggle group
Toggle buttons in one named group: aria-pressed on each item, single or multiple selection, one tab stop and arrow keys between the items. The value is read from the items; the root keeps no copy. Tier 1.
- Tier
- 1 · Delegated micro-behavior
- Behavior JS
- 1569 B brotli · 1756 B gzip ·
packages/· methodprimitives/ artifacts/ size.json - 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"
On this page
Example
<div
data-slean="toggle-group"
role="group"
aria-label="Text alignment"
data-slean-type="single"
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><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-type="single"
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>Why this implementation exists
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 in single mode 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
Usage
Install @svelte-lean/primitives for the behavior and @svelte-lean/styles for the stylesheet. With @svelte-lean/vite the
registration is injected for every static data-slean="toggle-group"; without it, import
the register module once.
npm install @svelte-lean/primitivespnpm add @svelte-lean/primitivesyarn add @svelte-lean/primitivesbun add @svelte-lean/primitivesimport '@svelte-lean/primitives/toggle-group/register';import '@svelte-lean/styles/toggle-group.css';Render every item with aria-pressed and a unique data-slean-value, and the roving tabindex: 0 on the first pressed item (else the first item), -1 on the others. Name the group with aria-label or aria-labelledby.
Set data-slean-type="single" for an exclusive choice and add data-slean-required when one item must stay pressed. Listen to slean:change: detail.value and detail.previous are lists of values in document order.
For a single choice that is a form value, use Segmented or a Radio group: they are native radios with a form value and work without JavaScript.
<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>Anatomy
| Part | Element | data-slean-part | Required | Notes |
|---|---|---|---|---|
| root | <div data-slean="toggle-group" role="group" aria-label="…"> | – | yes | Options: data-slean-type (multiple, single), -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. |
Runtime profile
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, in the runtime block, are the production registration with the core kernel.
Accessibility contract
role="group"with a name, so the group is announced when focus enters it; each item is a native<button>witharia-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;disabledremoves it from focus. - The single rule does not turn the items into radios: they stay toggle buttons, each announced as pressed or not pressed.
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 |
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
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 hydration
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.
Styling
toggle-group.css draws one bordered track around the items, fills the pressed items with the accent's soft color and an accent border, stacks the items 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 | 0.25rem | padding, min-block-size, border-radius |
--slean-border | var(--slean-neutral-6) | border |
--slean-radius-md | 0.625rem | border-radius |
--slean-surface | oklch(100% 0 0) | background |
--slean-fg | var(--slean-neutral-12) | color |
--slean-space-2 | 0.5rem | gap, padding-inline |
--slean-control-height-md | 2.25rem | min-block-size |
--slean-space-3 | 0.75rem | 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 | 1rem | padding-inline |
--slean-text-md | 1rem | font-size |
--slean-muted | var(--slean-neutral-3) | background |
--slean-accent | oklch(54% 0.19 258) | border-color |
--slean-accent-soft | oklch(95% 0.03 258) | background |
--slean-accent-soft-fg | oklch(42% 0.17 258) | color |
State selectors the stylesheet targets, all from the platform or ARIA: :disabled, :hover, [aria-disabled="true"], [aria-pressed="true"].
Variant attributes: data-slean-orientation (vertical); data-size (sm, lg).
Compatibility notes
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.
Examples
Multiple
Without data-slean-type every item is pressed and released on its own, and slean:change reports the whole list. The line under the group reads the event.
<div data-slean="toggle-group" role="group" aria-label="Text style">
<button
type="button"
data-slean-part="item"
data-slean-value="bold"
aria-pressed="true"
tabindex="0"
>
Bold
</button>
<button
type="button"
data-slean-part="item"
data-slean-value="italic"
aria-pressed="false"
tabindex="-1"
>
Italic
</button>
<button
type="button"
data-slean-part="item"
data-slean-value="underline"
aria-pressed="true"
tabindex="-1"
>
Underline
</button>
</div>Vertical
data-slean-orientation="vertical" stacks the items and moves between them with
ArrowDown and ArrowUp. role="group" has no aria-orientation, so the
orientation lives in the layout and the keys.
<div
data-slean="toggle-group"
role="group"
aria-label="View"
data-slean-type="single"
data-slean-required
data-slean-orientation="vertical"
data-slean-loop="false"
dir="rtl"
>In a toolbar
Inside a Toolbar the group handles no arrow key and writes no tabindex: its items become toolbar controls, so the toolbar stays one tab stop and
the arrow keys continue past the group's first and last items. Pressing is unchanged.
Testing
packages/primitives/tests/toggle-group.test.tsVitest: single, multiple, required, keyboard, RTL, nested groups, inside a toolbar, 1000 groupsapps/playground/tests/primitives/toggles.spec.tsPlaywright: toggle, toggle group and toolbar with real focus and keys, and an axe scanpackages/styles/testsstatic checks of the stylesheet (layers, tokens, specificity, dark parity)
Source
packages/primitives/src/toggle-group/contract.mdthe contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behaviorpackages/primitives/src/toggle-group/contract.tstyped constants: name, tier, base, parts, options, eventspackages/primitives/src/toggle-group/behavior.tsthe behavior definition and the value helperspackages/primitives/src/toggle-group/register.tsthe registration modulepackages/styles/css/toggle-group.cssthe optional stylesheet