sveltelean Primitives
Versionv0.2.0 GitHub

Example

Three toggles
<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>
The pressed state is aria-pressed and nothing else. Click, or focus and press Space: the browser turns the key into a click, which the behavior handles.

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/primitives
+layout.svelte or any client module
import '@svelte-lean/primitives/toggle/register';
stylesheets
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.

wrap.svelte
<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

PartElementdata-slean-partRequiredNotes
root<button type="button" data-slean="toggle" aria-pressed="false">–yesThe 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-pressed carries the state; screen readers announce the button as a toggle, pressed or not pressed.
  • The accessible name is the text or aria-label and stays the same in both states.
  • disabled removes the toggle from the tab order; aria-disabled="true" keeps it focusable and the behavior ignores it.

Keyboard

KeyWhenResult
TabanywhereReaches the toggle like any button (native)
Enter/Spacefocus on the toggleNative activation dispatches a click; the behavior flips aria-pressed

Platform features

FeatureBaselineOutside the target
<button> activationWidely availableNot applicable
aria-pressedARIA 1.2 toggle button, announced by screen readersNot 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:

TokenDefault (light)Applies to
--slean-space-20.5remgap, padding-inline
--slean-control-height-md2.25remmin-block-size, inline-size
--slean-space-10.25rempadding-block
--slean-space-30.75rempadding-inline
--slean-bordervar(--slean-neutral-6)border
--slean-radius-md0.625remborder-radius
--slean-fgvar(--slean-neutral-12)color
--slean-text-sm0.875remfont-size
--slean-font-weight-medium500font-weight
--slean-duration-fast100mstransition
--slean-easecubic-bezier(0.2, 0, 0, 1)transition
--slean-control-height-sm2remmin-block-size, inline-size
--slean-radius-sm0.375remborder-radius
--slean-control-height-lg2.75remmin-block-size, inline-size
--slean-space-41rempadding-inline
--slean-text-md1remfont-size
--slean-accentoklch(54% 0.19 258)border-color, background
--slean-accent-softoklch(95% 0.03 258)background
--slean-accent-soft-fgoklch(42% 0.17 258)color
--slean-mutedvar(--slean-neutral-3)background
--slean-muted-hovervar(--slean-neutral-4)background
--slean-control-border-disabledvar(--slean-border)border-color
--slean-control-bg-disabledvar(--slean-muted)background
--slean-fg-mutedvar(--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.

sizes
<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.

No change yet.
programmatic
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

Source