Primitives Actions
Toggle
A button with a pressed state: aria-pressed on a native button, flipped by one shared click handler after a cancelable event. Enter and Space are the button's own activation. Tier 1.
- Tier
- 1 · Delegated micro-behavior
- Behavior JS
- 888 B brotli · 1005 B gzip ·
packages/· methodprimitives/ artifacts/ size.json - Platform features
<button>,aria-pressed- Shared listeners
- click
- Per-instance listeners
- none
- Lazy state
- none
- Native base
<button type="button" aria-pressed>
On this page
Example
<button type="button" data-slean="toggle" aria-pressed="false">Bold</button>
<button type="button" data-slean="toggle" aria-pressed="true">Italic</button>
<button type="button" data-slean="toggle" aria-pressed="false" disabled>Underline</button><script lang="ts">
// With @svelte-lean/vite the register import is injected for the static data-slean="toggle".
import '@svelte-lean/styles/toggle.css';
</script>
<button type="button" data-slean="toggle" aria-pressed="false">Bold</button>
<button type="button" data-slean="toggle" aria-pressed="true">Italic</button>
<button type="button" data-slean="toggle" aria-pressed="false" disabled>Underline</button>Why this implementation exists
HTML has checkboxes and switches with a native checked state, but no pressed button. ARIA defines the toggle button as a <button> with aria-pressed, and a screen reader announces it as a toggle with its state. Something has to flip the attribute when the button is activated.
The browser already turns Enter and Space on a focused button into a click, so the whole behavior is one shared click handler that reads aria-pressed, dispatches slean:change and writes the new value. There is no keyboard code and no state outside the attribute.
The browser owns
- the button: focus, the tab order, the accessible name
- Enter and Space turning into a click (native activation)
- announcing the pressed state from aria-pressed (screen readers)
- the click event routed to the behavior
Svelte Lean owns
- flipping aria-pressed on click, after a cancelable slean:change
- setPressed() and isPressed() for programmatic use
- development validation of the element, type, state and name
- toggle.css: the pressed fill and border, sizes, forced colors
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"; 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/register';import '@svelte-lean/styles/toggle.css';Render aria-pressed="false" or "true" on the server, with type="button" so a click inside a form does not submit it. Give an icon-only toggle an aria-label; the name does not change with the state.
Listen to slean:change on the button or on any ancestor. detail.pressed is the state being written, detail.previous the state before; preventDefault() keeps the current state.
Use a checkbox or a switch instead when the value belongs to a form: the toggle has no form value.
<script lang="ts">
import type { ToggleChangeDetail } from '@svelte-lean/primitives/toggle';
let { readonly = false } = $props();
let bar: HTMLElement;
let wrap = $state(false);
$effect(() => {
// slean:change bubbles from the button before aria-pressed is written; preventDefault()
// keeps the current state. One listener on an ancestor serves every toggle inside it.
const onchange = (event: Event) => {
if (readonly) return event.preventDefault();
const { pressed } = (event as CustomEvent<ToggleChangeDetail>).detail;
if ((event.target as HTMLElement).id === 'wrap') wrap = pressed;
};
bar.addEventListener('slean:change', onchange);
return () => bar.removeEventListener('slean:change', onchange);
});
</script>
<div bind:this={bar}>
<button type="button" id="wrap" data-slean="toggle" aria-pressed="false">Wrap lines</button>
</div>
<pre class:wrap>…</pre>Anatomy
| Part | Element | data-slean-part | Required | Notes |
|---|---|---|---|---|
| root | <button type="button" data-slean="toggle" aria-pressed="false"> | – | yes | The root is the button. aria-pressed "true" or "false" rendered by the author; "mixed" counts as not pressed. aria-label when there is no text. |
Runtime profile
The toggle registers one click handler with the shared router and no keyboard handler. A thousand toggles keep one listener (tests/toggle.test.ts). Its bytes, in the runtime block, are the production registration with the core kernel.
Accessibility contract
- A native
<button>: focusable, in the tab order, activated by Enter and Space. aria-pressedcarries the state; screen readers announce the button as a toggle, pressed or not pressed.- The accessible name is the text or
aria-labeland stays the same in both states. disabledremoves the toggle from the tab order;aria-disabled="true"keeps it focusable and the behavior ignores it.
Keyboard
| Key | When | Result |
|---|---|---|
| Tab | anywhere | Reaches the toggle like any button (native) |
| Enter/Space | focus on the toggle | Native activation dispatches a click; the behavior flips aria-pressed |
Platform features
| Feature | Baseline | Outside the target |
|---|---|---|
<button> activation | Widely available | Not applicable |
aria-pressed | ARIA 1.2 toggle button, announced by screen readers | Not applicable |
Without JavaScript
The button renders its authored state and can be focused, but a click changes nothing. A setting that must be changed without JavaScript is a checkbox or a switch, whose checked state and form value are native.
Server rendering
Render aria-pressed with the initial state. The markup is complete without the runtime, and the register module is safe to import on the server.
Before hydration
Before the behavior loads, a click does nothing. The registration attaches no per-toggle state, so the first click after hydration works on the server's markup directly.
Styling
toggle.css draws the resting border, the pressed state as the accent's soft fill with an accent border (so the state does not rely on the fill alone), a dashed border for mixed, the small and icon-only sizes, and forced-colors fills in the system highlight colors. The tokens it reads:
| Token | Default (light) | Applies to |
|---|---|---|
--slean-space-2 | 0.5rem | gap, padding-inline |
--slean-control-height-md | 2.25rem | min-block-size, inline-size |
--slean-space-1 | 0.25rem | padding-block |
--slean-space-3 | 0.75rem | padding-inline |
--slean-border | var(--slean-neutral-6) | border |
--slean-radius-md | 0.625rem | border-radius |
--slean-fg | var(--slean-neutral-12) | 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, inline-size |
--slean-radius-sm | 0.375rem | border-radius |
--slean-control-height-lg | 2.75rem | min-block-size, inline-size |
--slean-space-4 | 1rem | padding-inline |
--slean-text-md | 1rem | font-size |
--slean-accent | oklch(54% 0.19 258) | border-color, background |
--slean-accent-soft | oklch(95% 0.03 258) | background |
--slean-accent-soft-fg | oklch(42% 0.17 258) | color |
--slean-muted | var(--slean-neutral-3) | background |
--slean-muted-hover | var(--slean-neutral-4) | background |
--slean-control-border-disabled | var(--slean-border) | border-color |
--slean-control-bg-disabled | var(--slean-muted) | background |
--slean-fg-muted | var(--slean-neutral-11) | color |
State selectors the stylesheet targets, all from the platform or ARIA: :active, :disabled, :hover, [aria-disabled="true"], [aria-pressed="mixed"], [aria-pressed="true"].
Variant attributes: data-size (sm, lg); data-icon-only.
Compatibility notes
A <button> and aria-pressed work in every browser and screen reader in use. In Safari and in Firefox on macOS a click does not focus a button, as for every button; keyboard use is unaffected.
Examples
Sizes and icon-only toggles
data-size="sm" uses the small control height; data-icon-only makes the
button square. An icon-only toggle is named with aria-label, and the name stays the
same whatever the state.
<button type="button" data-slean="toggle" data-size="sm" aria-pressed="false">Wrap lines</button>
<button
type="button"
data-slean="toggle"
data-size="sm"
data-icon-only
aria-pressed="true"
aria-label="Pin"
>
<svg aria-hidden="true" viewBox="0 0 16 16" width="16" height="16">…</svg>
</button>The change event
slean:change is dispatched from the button before aria-pressed is
written, with pressed and previous as booleans. It bubbles, so one listener
on an ancestor serves every toggle inside it.
import { isPressed, setPressed } from '@svelte-lean/primitives/toggle';
// The same cancelable slean:change as a click; false when a listener cancelled it.
setPressed(button, !isPressed(button));Testing
packages/primitives/tests/toggle.test.tsVitest: click, cancel, disabled, mixed, setPressed(), validation, 1000 togglesapps/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/contract.mdthe contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behaviorpackages/primitives/src/toggle/contract.tstyped constants: name, tier, base, parts, options, eventspackages/primitives/src/toggle/behavior.tsthe behavior definition, setPressed() and isPressed()packages/primitives/src/toggle/register.tsthe registration modulepackages/styles/css/toggle.cssthe optional stylesheet