# Toggle

> A button with aria-pressed: one shared click listener flips the pressed state, which lives in the attribute.

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

A button that stays pressed or not pressed, for a formatting option or a view setting that applies at once. Tier 1.

```svelte
<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>
```

Click, or focus and press Space: the browser turns the key into a click, which the behavior handles.

## Examples

### Looks

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

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

<!-- The variant and the color draw the pressed state; unpressed toggles share the neutral line. -->
{#each variants as variant}
	<div class="row">
		{#each colors as color}
			<button type="button" data-slean="toggle" data-variant={variant} data-color={color} aria-pressed="true">
				{color}
			</button>
		{/each}
		<button type="button" data-slean="toggle" data-variant={variant} aria-pressed="false">Off</button>
	</div>
{/each}
```

The variant and the color draw the pressed state; the last toggle of each row is unpressed.

### Icon-only

```svelte
<button type="button" data-slean="toggle" data-icon-only aria-pressed="true" 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="toggle" data-icon-only aria-pressed="false" 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="toggle" data-icon-only aria-pressed="false" 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>
```

data-icon-only makes the button square; aria-label names it in both states.

### Sizes

```svelte
<button type="button" data-slean="toggle" data-size="small" aria-pressed="true">Small</button>
<button type="button" data-slean="toggle" aria-pressed="true">Medium</button>
<button type="button" data-slean="toggle" data-size="large" aria-pressed="true">Large</button>
```

### States

```svelte
<button type="button" data-slean="toggle" aria-pressed="false">Off</button>
<button type="button" data-slean="toggle" aria-pressed="true">On</button>
<button type="button" data-slean="toggle" aria-pressed="mixed">Mixed</button>
<button type="button" data-slean="toggle" aria-pressed="false" disabled>Disabled</button>
```

aria-pressed="mixed" draws a dashed border, for a selection whose parts differ.

### Bound to content

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

	let wrap = $state(false);

	// slean:change is dispatched before aria-pressed is written; preventDefault() would keep it.
	const follow = (node: HTMLElement) =>
		on(node, 'slean:change', (event) => {
			wrap = (event as CustomEvent<ToggleChangeDetail>).detail.value;
		});
</script>

<button type="button" data-slean="toggle" data-size="small" aria-pressed="false" {@attach follow}>
	Wrap lines
</button>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<pre tabindex="0" class:wrap>const message = 'A line of code that is longer than the frame it is shown in, so it scrolls until the lines wrap.';</pre>

<style>
	pre {
		overflow-x: auto;
	}
	.wrap {
		white-space: pre-wrap;
	}
</style>
```

slean:change carries the new state; the application applies it.

## Usage

Install `@svelte-lean/primitives` and `@svelte-lean/styles`. With `@svelte-lean/vite` the behavior registers itself for every `data-slean="toggle"`; 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.css';
```

Render `aria-pressed="false"` or `"true"` with `type="button"`. Listen to `slean:change` on the button or any ancestor: `detail.value` is the state being written, `detail.previous` the one before, and `preventDefault()` keeps the current state.

Use a checkbox or a switch when the value belongs to a form: the toggle has no form value.

`programmatic`

```ts
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));
```

## 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. |
| start icon | `<svg> or <span> around one` | `start-icon` | no | An icon before or after the label (MUI startIcon and endIcon); sized and spaced by the stylesheet. |
| end icon | `<svg> or <span> around one` | `end-icon` | no | An icon after the label. |

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

## Styling

`toggle.css` draws the resting border, the pressed state filled with the current color in the `contained` variant (or a soft tint and a colored line with `outlined`, a tint alone with `text`), a dashed border for `mixed`, the sizes and the icon-only square, and forced-colors fills in the system highlight colors. The tokens it reads:

| Token | Default (light) | Applies to |
| --- | --- | --- |
| `--slean-space-2` | `calc(var(--slean-spacing) * 2)` | gap, padding-inline |
| `--slean-control-height-md` | `2.25rem` | min-block-size, inline-size |
| `--slean-space-1` | `calc(var(--slean-spacing) * 1)` | padding-block |
| `--slean-space-3` | `calc(var(--slean-spacing) * 3)` | padding-inline |
| `--slean-border` | `var(--slean-neutral-6)` | border |
| `--slean-button-radius` | `var(--slean-radius-md)` | border-radius |
| `--slean-fg` | `var(--slean-neutral-12)` | color, background |
| `--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-control-height-lg` | `2.75rem` | min-block-size, inline-size |
| `--slean-space-4` | `calc(var(--slean-spacing) * 4)` | padding-inline |
| `--slean-text-md` | `1rem` | font-size |
| `--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-muted` | `var(--slean-neutral-3)` | background |
| `--slean-color-dark` | `var(--slean-primary-dark)` | 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-variant` (`text`, `outlined`); `data-size` (`small`, `large`); `data-icon-only`.

## Under the hood

Runtime and size

- **Tier**: 1 · Delegated micro-behavior
- **Behavior JS**: not measured
- **Platform features**: `<button>`, `aria-pressed`
- **Shared listeners**: click
- **Per-instance listeners**: none
- **Lazy state**: none
- **Native base**: `<button type="button" aria-pressed>`

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 are the production registration with the core kernel.

Why this implementation

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 a cancelable `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

Accessibility

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

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 change without JavaScript is a checkbox or a switch.

Server rendering and hydration

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

Compatibility

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.

Tests and source

- `packages/primitives/tests/toggle.test.ts` Vitest: click, cancel, disabled, mixed, setPressed(), validation, 1000 toggles
- `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/contract.md` the contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behavior
- `packages/primitives/src/toggle/contract.ts` typed constants: name, tier, base, parts, options, events
- `packages/primitives/src/toggle/behavior.ts` the behavior definition, setPressed() and isPressed()
- `packages/primitives/src/toggle-group/register.ts` the registration module
- `packages/styles/css/toggle.css` the optional stylesheet
