# Toggle button

> A button that holds an on or off state with aria-pressed and a label that never changes. Off is a quiet outlined surface; on fills with the accent and its leading mark fills and ticks, so the state reads in shape as well as colour.

- ID: `cmp_toggle_button_01`
- Slug: `toggle-button-01`
- Version: `1.0.0` (current)
- Status: published
- Published: 2026-10-21T23:47:39Z
- Updated: 2026-10-22
- Available versions: `1.0.0`
- Kind: control
- Primary category: `buttons`
- Detail page: https://pagesugar.com/components/toggle-button-01
- Preview: https://pagesugar.com/preview/toggle-button-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `neutral` | Neutral | yes | `sha256-45562ec408107988cacaa85ca1c57c49efd34718f20fc4aba4cbdbd637ac25b2` |
| `blue` | Blue accent | no | `sha256-8f5bca713dc50d351810f959d236d20f1b8f9f129d65399bcc999194a87b8104` |

## Runtime and compatibility

- Runtime: svelte
- Svelte: 5
- SvelteKit required: no (portable Svelte component)
- Imports: The source imports its own files by relative path only. Examples import from $lib/...; SvelteKit 3 projects have no $lib alias by default, so import from your #lib subpath (with the .svelte extension) or a relative path instead.
- Tailwind CSS: 4
- SSR: supported
- Requires client-side JavaScript: yes
- Integration level: local-interaction
- Appearance modes: light
- Suggested directory: `src/lib/components/toggle-button-01`

## Dependencies

No third-party runtime packages.

## Services

No external services required.

## Usage

Pass a label and bind pressed (or pass pressed with onPressedChange). It renders a native button with aria-pressed that flips on click, Enter and Space. It does not store the state anywhere, group with sibling toggles into a single or multiple choice, render a link, or act as a settings switch; use a toggle group for exclusive choices and a switch for a setting that takes effect at once.

Required props: `label`

```svelte
<script lang="ts">
	import ToggleButton from '$lib/components/toggle-button-01/ToggleButton.svelte';

	let showGrid = $state(true);
	let favourite = $state(false);
</script>

{#snippet star(on: boolean)}
	<svg viewBox="0 0 16 16" fill="none">
		<path
			d="m8 2.25 1.75 3.6 3.95.55-2.87 2.77.7 3.93L8 11.22 4.47 13.1l.7-3.93L2.3 6.4l3.95-.55z"
			fill={on ? 'currentColor' : 'none'}
			stroke="currentColor"
			stroke-width="1.5"
			stroke-linejoin="round"
		/>
	</svg>
{/snippet}

<div class="flex items-center gap-2">
	<ToggleButton label="Show grid" bind:pressed={showGrid} />
	<ToggleButton
		label="Add to favourites"
		iconOnly
		icon={star}
		pressed={favourite}
		onPressedChange={(next) => (favourite = next)}
	/>
</div>
```

Limitations:

- It does not persist the state; the consumer stores it and passes it back.
- pressed is bindable. Passed without bind and without onPressedChange, the button still flips locally, and the parent's value wins again the next time it changes.
- It is a single toggle. A set of toggles with single or multiple selection belongs in a toggle group, which adds arrow-key navigation.
- Without an icon, a ring mark leads the label and fills with a tick when on. With an icon, the icon replaces the mark: swap it to a filled glyph when pressed so the state is not carried by fill alone.
- class goes on the button, so placement utilities such as w-full or margins work; other attributes pass through, but type, aria-pressed, aria-label and onclick are owned by the component.
- A blank label throws when the component is created, so an unnamed toggle fails in development instead of shipping.

## Usage guide

### Toggle button

A button that stays on or off: Show grid, Bold, Save to favourites. It renders a native
`<button>` with `aria-pressed`, flips on click, Enter and Space, and keeps its label the same in
both states. Screen readers announce the state; the label names the option.

#### State

Bind it:

```svelte
<ToggleButton label="Show grid" bind:pressed={showGrid} />
```

Or pass it down and hear about changes:

```svelte
<ToggleButton label="Show grid" pressed={showGrid} onPressedChange={(next) => save(next)} />
```

`onPressedChange` runs after the state flips and never while the button is disabled. The
component does not store the state; persist it yourself.

#### How on and off look

| State    | Fill                      | Edge                    | Mark                      |
| -------- | ------------------------- | ----------------------- | ------------------------- |
| Off      | `--toggle-button-surface` | one-pixel hairline      | an empty ring             |
| On       | `--toggle-button-accent`  | none, a faint top light | a filled disc with a tick |
| Disabled | the same, at 50% opacity  | the same                | the same                  |

The mark's box is always there, and the label's weight never changes, so the button keeps its
width when it flips and nothing beside it moves.

#### Icons

Pass `icon` to replace the ring with your own glyph. The snippet receives the pressed state:

```svelte
{#snippet heart(on: boolean)}
	<svg viewBox="0 0 16 16" fill="none">
		<path d="..." fill={on ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="1.5" />
	</svg>
{/snippet}

<ToggleButton label="Save to favourites" icon={heart} bind:pressed={saved} />
```

Swap to a filled glyph when on, so the state is carried by shape as well as by the fill. With
`iconOnly`, the label becomes the `aria-label` and the button is a 32 or 36 px square.

#### Toggle button or switch

Use a toggle button for an option or tool that the user turns on as an action: a view option, a
text style, a favourite. If the control is a setting that takes effect at once and reads as on or
off (Email notifications, Airplane mode), a switch is clearer. For several toggles where one or
more can be chosen, use a toggle group.

## Props

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `pressed` | `boolean` | no | `false` | Current state, rendered as aria-pressed. $bindable: use bind:pressed, or pass it with onPressedChange. |
| `onPressedChange` | `(pressed: boolean) => void` | no |  | Called with the new state after every click, Enter or Space. Never called while disabled. |
| `label` | `string` | yes |  | The visible label, or the aria-label when iconOnly is set. Must not be blank. Keep it the same in both states: 'Show grid', not 'Hide grid'. |
| `iconOnly` | `boolean` | no | `false` | Hide the label visually and use it as the aria-label, for a square 32 or 36 px toggle. Pass an icon with it. |
| `icon` | `Snippet<[boolean]>` | no |  | Leading icon, decorative, in place of the ring mark. Receives the pressed state, so it can swap to a filled glyph. Inline SVG with currentColor, sized 14 px at sm and 16 px at md, and 16 px in an icon-only square at either size. |
| `disabled` | `boolean` | no | `false` | Native disabled. The state still shows, dimmed, and onPressedChange is not called. |
| `size` | 'sm' \| 'md' | no | `'md'` | sm is 32 px tall with 13 px text, md 36 px with 14 px text, matching action-button-01. Both grow to 44 px on coarse pointers. |
| `class` | `string` | no |  | Extra classes on the button, for placement such as margins, w-full or grid placement. |

## Customization

Change the label, icon and size through props, and retone both states through six --toggle-button-\* CSS variables. Hover tones are mixed from the fill and its label colour, so they follow any accent.

- Accent: --toggle-button-accent is the pressed fill and the focus ring; --toggle-button-on-accent is the pressed label, icon and the filled mark. Keep the pair at 4.5:1 for the label.
- Neutrals: --toggle-button-surface is the unpressed fill, --toggle-button-ink its label, --toggle-button-muted its icon and the empty ring, and --toggle-button-hairline its edge.
- Worked retone for a #09090b band: --toggle-button-accent: #fafafa; --toggle-button-on-accent: #09090b; --toggle-button-surface: #18181b; --toggle-button-ink: #fafafa; --toggle-button-muted: #a1a1aa; --toggle-button-hairline: rgb(255 255 255 / 0.12).
- Icons: pass a snippet that takes the pressed state and returns an inline SVG on a 16 px viewBox, outlined when off and filled when on (a star, a heart, a bookmark). Without an icon, the ring mark carries the state.
- Layout: put several in a flex row with gap-2, so the two-pixel focus ring never touches the next button. In a narrow filter column, pass class="w-full" and the mark stays on the start edge while a long label wraps.
- Behaviour: if the action is a setting that takes effect at once and reads as on or off rather than as an action (Airplane mode, Email notifications), a switch is clearer than a toggle button.

| Token | Public CSS variable |
| --- | --- |
| `accent` | `--toggle-button-accent` |
| `onAccent` | `--toggle-button-on-accent` |
| `ink` | `--toggle-button-ink` |
| `muted` | `--toggle-button-muted` |
| `hairline` | `--toggle-button-hairline` |
| `surface` | `--toggle-button-surface` |

## Accessibility

- Follows the WAI-ARIA APG button pattern, toggle variant: a native \<button type="button"\> with aria-pressed="true" or "false", so Enter and Space flip it with no extra handlers.
- The label is the accessible name and does not change with the state; assistive technology announces 'Show grid, toggle button, pressed'. Do not pass a label such as 'Hide grid' when on.
- With iconOnly the label becomes aria-label. The mark and icon box are aria-hidden; pass a plain SVG with nothing focusable inside.
- On and off differ in fill, edge and mark shape (an empty ring against a filled disc with a tick), so the state does not rely on colour. With an icon, swap it to a filled glyph when on for the same reason.
- Focus shows a two-pixel ring in the accent, two pixels outside the button's radius. Keep the accent at 3:1 against the page.
- Targets are 32 and 36 px, above the 24 px of WCAG 2.2 2.5.8, and grow to 44 px on coarse pointers.
- Disabled uses the native attribute, which removes the button from the tab order. Explain nearby why the option is locked.
- If the control is really a setting that takes effect immediately, a switch (role="switch") may be clearer to users of assistive technology.

## License

- Declared source: MIT
- Default license approval is pending. See https://pagesugar.com/docs/license.

## Source

- Palette: Neutral (`neutral`)
- Entry: `ToggleButton.svelte`
- Suggested directory: `src/lib/components/toggle-button-01`
- Files: 1
- Artifact digest: `sha256-45562ec408107988cacaa85ca1c57c49efd34718f20fc4aba4cbdbd637ac25b2`

Paths below are relative to the suggested directory. Copy the files as they are;
they import nothing from this site.

#### `ToggleButton.svelte`

Role: entry · 7203 bytes · SHA-256 `23958d06890ea053498afcdeafe80a6c460ddd3dce8ebbc13e63a0dabc2b9b3b`

```svelte
<!--
	A button that holds an on or off state: Show grid, Bold, Save to favourites. It renders a native
	<button> with aria-pressed and a label that never changes. Off is a quiet surface with a hairline;
	on floods with the accent. A leading mark carries the state in shape as well: a hollow ring that
	fills and takes a tick, or the consumer's icon, which receives the pressed state to swap glyphs.
	The mark's box is always there, so the button never changes width when it flips.
-->
<script lang="ts" module>
	export type ToggleButtonSize = 'sm' | 'md';
</script>

<script lang="ts">
	import type { Snippet } from 'svelte';
	import type { HTMLButtonAttributes } from 'svelte/elements';

	type Passthrough = Omit<
		HTMLButtonAttributes,
		| 'type'
		| 'disabled'
		| 'children'
		| 'onclick'
		| 'class'
		| 'title'
		| 'aria-label'
		| 'aria-labelledby'
		| 'aria-pressed'
	>;

	interface Props extends Passthrough {
		/** Current state. Bind it with bind:pressed, or pass it with onPressedChange. */
		pressed?: boolean;
		/** Called with the new state after every flip. Never called while disabled. */
		onPressedChange?: (pressed: boolean) => void;
		/** The visible label, or the accessible name when iconOnly is set. Keep it the same in both states. */
		label: string;
		/** Hide the label visually and use it as the aria-label. Pass an icon with it. */
		iconOnly?: boolean;
		/** Leading icon, decorative. Receives the pressed state, so it can swap to a filled glyph. */
		icon?: Snippet<[boolean]>;
		/** Native disabled. The state still shows, dimmed. */
		disabled?: boolean;
		/** sm 32 px, md 36 px tall; both grow to 44 px on touch screens. */
		size?: ToggleButtonSize;
		/** Extra classes for placement (margins, grid placement, w-full). */
		class?: string;
	}

	let {
		pressed = $bindable(false),
		onPressedChange,
		label,
		iconOnly = false,
		icon,
		disabled = false,
		size = 'md',
		class: className,
		...rest
	}: Props = $props();

	/* An unnamed toggle is invisible to assistive technology, so a blank label is a bug. */
	const name = $derived.by(() => {
		if (!label?.trim()) throw new Error('ToggleButton: label is required and must not be blank.');
		return label;
	});

	function toggle() {
		if (disabled) return;
		pressed = !pressed;
		onPressedChange?.(pressed);
	}

	/* The mark sits in a box one line tall, so it stays level with the first line of a wrapped label. */
	const markBox = $derived(iconOnly ? 'size-4' : size === 'sm' ? 'h-lh w-3.5' : 'h-lh w-4');
</script>

<button
	{...rest}
	type="button"
	{disabled}
	aria-pressed={pressed ? 'true' : 'false'}
	aria-label={iconOnly ? name : undefined}
	class={[
		'toggle-button inline-flex max-w-full shrink-0 cursor-pointer items-center font-medium select-none',
		/* Stretched to a column's width, a labelled toggle keeps its mark on the start edge. */
		iconOnly ? 'justify-center' : 'justify-start',
		'bg-(--_fill) text-(--_label) shadow-(--_edge) hover:bg-(--_fill-hover)',
		'transition-[color,background-color,box-shadow,scale] duration-150 ease-(--_ease) active:scale-(--_press) active:duration-[80ms] motion-reduce:transition-[color,background-color,box-shadow] motion-reduce:active:scale-100',
		'outline-offset-2 outline-(--_accent) focus-visible:outline-2',
		'disabled:cursor-not-allowed disabled:opacity-50',
		size === 'sm' ? 'rounded-md text-[13px] leading-4' : 'rounded-lg text-sm leading-5',
		iconOnly && size === 'sm' && 'size-8 pointer-coarse:size-11',
		iconOnly && size === 'md' && 'size-9 pointer-coarse:size-11',
		!iconOnly && size === 'sm' && 'min-h-8 px-3 py-2 pointer-coarse:min-h-11',
		!iconOnly && size === 'md' && 'min-h-9 px-4 py-2 pointer-coarse:min-h-11',
		className
	]}
	onclick={toggle}
>
	<!-- One row aligned to the label's first line. sm's 6 px gap is DESIGN §3.8's button table. -->
	<span class={['flex min-w-0 items-start', size === 'sm' ? 'gap-1.5' : 'gap-2']}>
		<span
			class={['toggle-button__icon grid shrink-0 place-items-center text-(--_icon)', markBox]}
			aria-hidden="true"
		>
			{#if icon}
				{@render icon(pressed)}
			{:else}
				<!-- The default mark: a ring at rest, a filled disc with a tick when on. -->
				<span
					class={[
						'grid place-items-center rounded-full bg-(--_mark-fill) text-(--_mark-ink) shadow-(--_mark-edge)',
						'transition-[background-color,box-shadow] duration-150 ease-(--_ease) motion-reduce:transition-none',
						size === 'sm' ? 'size-3' : 'size-3.5'
					]}
				>
					<svg
						class={[
							'scale-(--_tick-scale) opacity-(--_tick)',
							'transition-[opacity,scale] duration-150 ease-(--_ease) motion-reduce:transition-[opacity]',
							size === 'sm' ? 'size-2' : 'size-2.5'
						]}
						viewBox="0 0 10 10"
						fill="none"
					>
						<path
							d="M2.25 5.25 4.25 7.25 7.75 3"
							stroke="currentColor"
							stroke-width="1.5"
							stroke-linecap="round"
							stroke-linejoin="round"
						/>
					</svg>
				</span>
			{/if}
		</span>
		{#if !iconOnly}
			<span class="min-w-0 text-start text-pretty break-words">{name}</span>
		{/if}
	</span>
</button>

<style>
	/* Public tokens: set --toggle-button-* on the button or any ancestor to retone it. */
	.toggle-button {
		--_accent: var(--toggle-button-accent, #18181b);
		--_on-accent: var(--toggle-button-on-accent, #ffffff);
		--_ink: var(--toggle-button-ink, #18181b);
		--_muted: var(--toggle-button-muted, #52525b);
		--_hairline: var(--toggle-button-hairline, rgb(0 0 0 / 0.12));
		--_surface: var(--toggle-button-surface, #ffffff);

		--_ease: cubic-bezier(0.2, 0, 0, 1);
		--_press: 0.98;
	}

	/*
	 * Each state fills these in; the utilities above only ever read them. Hover moves each fill
	 * one step toward its own label colour, so it follows any accent.
	 */
	.toggle-button[aria-pressed='false'] {
		--_fill: var(--_surface);
		--_fill-hover: color-mix(in oklab, var(--_surface) 95%, var(--_ink));
		--_label: var(--_ink);
		--_icon: var(--_muted);
		/* Inset, so the button is exactly the same size on and off. */
		--_edge: inset 0 0 0 1px var(--_hairline), 0 1px 2px rgb(0 0 0 / 0.05);
		--_mark-fill: transparent;
		--_mark-ink: var(--_muted);
		--_mark-edge: inset 0 0 0 1.5px var(--_muted);
		--_tick: 0;
		--_tick-scale: 0.5;
	}

	.toggle-button[aria-pressed='true'] {
		--_fill: var(--_accent);
		--_fill-hover: color-mix(in oklab, var(--_accent) 88%, var(--_on-accent));
		--_label: var(--_on-accent);
		--_icon: var(--_on-accent);
		--_edge: inset 0 1px 0 rgb(255 255 255 / 0.12), 0 1px 2px rgb(0 0 0 / 0.08);
		--_mark-fill: var(--_on-accent);
		--_mark-ink: var(--_accent);
		--_mark-edge: 0 0 #0000;
		--_tick: 1;
		--_tick-scale: 1;
	}

	/*
	 * The consumer's icon is snippet content the component does not render, so its size is a
	 * layered default: it fills the box's width (14 or 16 px) and stays square, and a class on the
	 * consumer's own <svg> still wins. Only a direct child is sized, never the built-in tick.
	 */
	@layer components {
		.toggle-button__icon > :global(:where(svg)) {
			width: 100%;
			height: auto;
		}
	}

	/* Disabled toggles neither change tone on hover nor press, in either state. */
	.toggle-button:disabled {
		--_fill-hover: var(--_fill);
		--_press: 1;
	}
</style>
```

## Artifacts

### Neutral (`neutral`) (default)

- Artifact digest: `sha256-45562ec408107988cacaa85ca1c57c49efd34718f20fc4aba4cbdbd637ac25b2`
- Entry: `ToggleButton.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_toggle_button_01/1.0.0/neutral/sha256-45562ec408107988cacaa85ca1c57c49efd34718f20fc4aba4cbdbd637ac25b2/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_toggle_button_01/1.0.0/neutral/sha256-45562ec408107988cacaa85ca1c57c49efd34718f20fc4aba4cbdbd637ac25b2/bundle.zip (6218 bytes, sha256 `92379bf65bde6182eb63a6c9768c942575f7cdd18797358ad57734bbd708fd43`)

Files:

- `ToggleButton.svelte` (entry, 7203 bytes): https://pagesugar.com/artifacts/cmp_toggle_button_01/1.0.0/neutral/sha256-45562ec408107988cacaa85ca1c57c49efd34718f20fc4aba4cbdbd637ac25b2/source/ToggleButton.svelte

### Blue accent (`blue`)

- Artifact digest: `sha256-8f5bca713dc50d351810f959d236d20f1b8f9f129d65399bcc999194a87b8104`
- Entry: `ToggleButton.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_toggle_button_01/1.0.0/blue/sha256-8f5bca713dc50d351810f959d236d20f1b8f9f129d65399bcc999194a87b8104/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_toggle_button_01/1.0.0/blue/sha256-8f5bca713dc50d351810f959d236d20f1b8f9f129d65399bcc999194a87b8104/bundle.zip (6222 bytes, sha256 `d30f63fd05e5ce39200868f1fff29f1bca46b5a41fd48a6ded513be49ac3c229`)

Files:

- `ToggleButton.svelte` (entry, 7203 bytes): https://pagesugar.com/artifacts/cmp_toggle_button_01/1.0.0/blue/sha256-8f5bca713dc50d351810f959d236d20f1b8f9f129d65399bcc999194a87b8104/source/ToggleButton.svelte
