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.
cmp_toggle_button_01 Preview
Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.
Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
Using the PageSugar MCP server, fetch component cmp_toggle_button_01 version 1.0.0 with variant "neutral", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.Not connected yet? Set up the MCP server
Code
- Palette
- Neutral
- Version
- 1.0.0
- Digest
Full digest
sha256-45562ec408107988cacaa85ca1c57c49efd34718f20fc4aba4cbdbd637ac25b2
<!--
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>
Usage#
On this pagePass 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.
- Suggested location
src/lib/components/toggle-button-01- Required props
label
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-fullor margins work; other attributes pass through, but type,aria-pressed,aria-labeland 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.
Example
<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>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.
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:
<ToggleButton label="Show grid" bind:pressed={showGrid} />Or pass it down and hear about changes:
<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:
{#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 and content inputs#
On this page| 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 | None | Called with the new state after every click, Enter or Space. Never called while disabled. |
label | string | Yes | None | 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 | None | 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 | None | Extra classes on the button, for placement such as margins, w-full or grid placement. |
Customization#
On this pageChange 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-accentis the pressed fill and the focus ring;--toggle-button-on-accentis the pressed label, icon and the filled mark. Keep the pair at 4.5:1 for the label. - Neutrals:
--toggle-button-surfaceis the unpressed fill,--toggle-button-inkits label,--toggle-button-mutedits icon and the empty ring, and--toggle-button-hairlineits edge. - Worked retone for a
#09090bband:--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.
Public CSS variables
| Variable | Token |
|---|---|
--toggle-button-accent | accent |
--toggle-button-on-accent | onAccent |
--toggle-button-ink | ink |
--toggle-button-muted | muted |
--toggle-button-hairline | hairline |
--toggle-button-surface | surface |
Accessibility#
On this page- 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
iconOnlythe label becomesaria-label. The mark and icon box arearia-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.
Release details#
On this page- Integration
- Local interaction
- Requires client-side JavaScript to be interactive
- Server-side rendering supported
- Dependencies
- No additional runtime packages beyond Svelte and Tailwind CSS
- License
MIT. Default license approval is pending; see the license status before adopting the source.
- Version history
- 1.0.0 (Published) Current release · 21 October 2026
Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.