Switch
An on-or-off setting that takes effect at once, as a role="switch" button with a fixed label and optional description. Off is an outlined track with a cross; on fills with a check. Async saves show pending and move back on failure.
cmp_switch_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_switch_01 version 1.0.0 with variant "blue", 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
- Blue accent
- Version
- 1.0.0
- Digest
Full digest
sha256-7fdbcba475cebcec4d463dfe50f9d78e3d4ffb03d01a37ff8a76fab94604b013
<!--
An on-or-off setting that takes effect at once: Email notifications, Show weekends, Dark mode.
A <button role="switch"> with aria-checked, named by a visible <label> that never changes. Off is
an outlined track with a muted thumb carrying a cross; on fills the track with the accent and the
thumb carries a check, so the state reads in shape and position as well as colour. If the
callback returns a promise, the switch moves at once, shows a spinner in the thumb while it
waits, and moves back with a message if the save fails.
-->
<script lang="ts" module>
export type SwitchSize = 'sm' | 'md';
export type SwitchLabelPosition = 'start' | 'end';
export type SwitchResult = { ok: boolean; message?: string };
</script>
<script lang="ts">
interface Props {
/** Current state. Bind it with bind:checked, or pass it with onCheckedChange. */
checked?: boolean;
/** The visible label and accessible name. Name the setting, not the action. */
label: string;
/** Help text under the label, read as the switch's description. */
description?: string;
/**
* Called with the new state after every flip. Return a promise to save asynchronously: the
* switch shows pending until it settles and moves back if it resolves { ok: false } or rejects.
*/
onCheckedChange?: (checked: boolean) => void | Promise<SwitchResult>;
/** Shown when a save fails without a message of its own. */
errorMessage?: string;
/** Form field name. When set, a hidden input posts value while the switch is on. */
name?: string;
/** The value posted while on. */
value?: string;
/** Native disabled. The state still shows, dimmed, and the callback is not called. */
disabled?: boolean;
/** sm is a 36 × 20 track with 13 px text, md a 44 × 24 track with 14 px text. */
size?: SwitchSize;
/** start puts the label first and the switch on the end edge (a settings row); end leads with the switch. */
labelPosition?: SwitchLabelPosition;
/** Extra classes on the row, for placement. */
class?: string;
}
let {
checked = $bindable(false),
label,
description,
onCheckedChange,
errorMessage = 'That change was not saved, so the switch has moved back.',
name,
value = 'on',
disabled = false,
size = 'md',
labelPosition = 'start',
class: className
}: Props = $props();
const uid = $props.id();
const ids = {
control: `${uid}-control`,
label: `${uid}-label`,
description: `${uid}-description`,
error: `${uid}-error`
};
/* An unnamed switch is invisible to assistive technology, so a blank label is a bug. */
const text = $derived.by(() => {
if (!label?.trim()) throw new Error('Switch: label is required and must not be blank.');
return label;
});
let pending = $state(false);
let error = $state('');
/* Only the latest flip may settle the switch; a save that settles after unmount is ignored. */
let attempt = 0;
$effect(() => () => {
attempt++;
});
function fail(previous: boolean, message?: string) {
checked = previous;
error = message?.trim() || errorMessage;
}
async function toggle() {
if (disabled || pending) return;
const previous = checked;
const next = !previous;
checked = next;
error = '';
let result: ReturnType<NonNullable<Props['onCheckedChange']>>;
try {
result = onCheckedChange?.(next);
if (!result || typeof result.then !== 'function') return;
} catch {
fail(previous);
return;
}
const current = ++attempt;
pending = true;
let outcome: SwitchResult | undefined;
try {
outcome = await result;
} catch {
outcome = { ok: false };
}
if (current !== attempt) return;
pending = false;
/* The parent set checked itself while the save ran; its value wins over a revert. */
if (outcome?.ok || checked !== next) return;
fail(previous, outcome?.message);
}
const describedBy = $derived(
[error && ids.error, description && ids.description].filter(Boolean).join(' ') || undefined
);
const sm = $derived(size === 'sm');
</script>
<div
class={[
'switch flex items-start gap-3',
labelPosition === 'end' && 'flex-row-reverse justify-end',
className
]}
>
<div class={['min-w-0', labelPosition === 'start' && 'flex-1', !sm && 'pt-0.5']}>
<!-- A <label> on the button: clicking it toggles, and it is the switch's whole name. -->
<label
id={ids.label}
for={ids.control}
class={[
'switch__label block font-medium text-pretty break-words text-(--_ink) select-none',
sm ? 'text-[13px] leading-5' : 'text-sm leading-5',
disabled ? 'cursor-not-allowed opacity-50' : pending ? 'cursor-progress' : 'cursor-pointer'
]}>{text}</label
>
<!-- Description and error share one cell, so a failed save never pushes the rows below. -->
<div class={['grid max-w-[35em]', sm ? 'text-xs leading-4' : 'text-[13px] leading-5']}>
{#if description}
<p
id={ids.description}
class={[
'col-start-1 row-start-1 text-pretty break-words text-(--_muted)',
disabled && 'opacity-50',
error && 'invisible'
]}
>
{description}
</p>
{/if}
<p
id={ids.error}
class="col-start-1 row-start-1 flex items-start gap-2 text-pretty break-words text-(--_error)"
role="status"
aria-atomic="true"
>
{#if error}
<span class={['grid shrink-0 place-items-center', sm ? 'h-4' : 'h-5']} aria-hidden="true">
<svg class={sm ? 'size-3' : 'size-3.5'} viewBox="0 0 14 14" fill="none">
<circle cx="7" cy="7" r="5.75" stroke="currentColor" stroke-width="1.5" />
<path
d="M7 4.25v3.25"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
<circle cx="7" cy="9.75" r=".875" fill="currentColor" />
</svg>
</span>
<span>{error}</span>
{/if}
</p>
</div>
</div>
<button
id={ids.control}
type="button"
role="switch"
aria-checked={checked ? 'true' : 'false'}
aria-labelledby={ids.label}
aria-describedby={describedBy}
aria-busy={pending ? 'true' : undefined}
aria-disabled={pending ? 'true' : undefined}
{disabled}
class={[
'switch__track group relative inline-flex shrink-0 items-center rounded-full p-1',
'bg-(--_fill) shadow-(--_edge)',
'transition-[background-color,box-shadow] duration-150 ease-(--_ease) motion-reduce:transition-none',
'outline-offset-2 outline-(--_accent) focus-visible:outline-2',
/* An invisible box around the track brings the target to 44 px tall without moving the row. */
'before:absolute before:-inset-x-2 before:-inset-y-3 before:content-[""]',
sm ? 'h-5 w-9' : 'h-6 w-11',
disabled ? 'cursor-not-allowed opacity-50' : pending ? 'cursor-progress' : 'cursor-pointer'
]}
onclick={toggle}
>
<span
class={[
'switch__thumb grid place-items-center rounded-full bg-(--_thumb) text-(--_glyph)',
'transition-[translate,scale,background-color] duration-200 ease-(--_ease)',
'group-active:scale-x-115 group-active:duration-[80ms]',
'motion-reduce:transition-[background-color] motion-reduce:group-active:scale-x-100',
sm
? 'size-3 group-aria-checked:translate-x-4 rtl:group-aria-checked:-translate-x-4'
: 'size-4 group-aria-checked:translate-x-5 rtl:group-aria-checked:-translate-x-5'
]}
aria-hidden="true"
>
{#if pending}
<svg
class={['switch__spinner', sm ? 'size-2.5' : 'size-3']}
viewBox="0 0 12 12"
fill="none"
>
<circle
cx="6"
cy="6"
r="4.5"
stroke="currentColor"
stroke-opacity=".2"
stroke-width="2"
/>
<path
d="M6 1.5a4.5 4.5 0 0 1 4.5 4.5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
</svg>
{:else if checked}
<svg class={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>
{:else}
<svg class={sm ? 'size-2' : 'size-2.5'} viewBox="0 0 10 10" fill="none">
<path
d="M3 3l4 4M7 3 3 7"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
</svg>
{/if}
</span>
</button>
{#if name && checked && !disabled}
<input type="hidden" {name} {value} />
{/if}
</div>
<style>
/* Public tokens: set --switch-* on the row or any ancestor to retone it. */
.switch {
--_accent: var(--switch-accent, #1d4ed8);
--_on-accent: var(--switch-on-accent, #ffffff);
--_ink: var(--switch-ink, #18181b);
--_muted: var(--switch-muted, #52525b);
--_track: var(--switch-track, #71717a);
--_hairline: var(--switch-hairline, #71717a);
--_error: var(--switch-error, #b91c1c);
--_ease: cubic-bezier(0.2, 0, 0, 1);
}
/*
* Each state fills these in; the utilities above only read them. Off is an outline at 4.5:1
* against white with a faint wash inside; on is a solid fill. Hover moves each one step.
*/
.switch__track[aria-checked='false'] {
--_fill: color-mix(in oklab, var(--_track) 8%, transparent);
--_fill-hover: color-mix(in oklab, var(--_track) 16%, transparent);
--_edge: inset 0 0 0 1px var(--_hairline);
--_thumb: var(--_track);
--_glyph: var(--_on-accent);
}
.switch__track[aria-checked='true'] {
--_fill: var(--_accent);
--_fill-hover: color-mix(in oklab, var(--_accent) 86%, var(--_on-accent));
--_edge: inset 0 0 0 1.5px var(--_accent);
--_thumb: var(--_on-accent);
--_glyph: var(--_accent);
}
/*
* Hovering the label hovers the track too, because a click there lands on the switch. A
* relation between two elements, so it lives here, inside the hover guard.
*/
@media (hover: hover) {
.switch:has(.switch__label:hover) .switch__track:enabled:not([aria-busy]),
.switch__track:enabled:not([aria-busy]):hover {
--_fill: var(--_fill-hover);
}
}
/* The thumb stretches toward its travel while pressed, from the side it rests on. */
.switch__thumb {
transform-origin: 0 50%;
}
.switch__track[aria-checked='true'] .switch__thumb,
.switch__track:dir(rtl) .switch__thumb {
transform-origin: 100% 50%;
}
.switch__track[aria-checked='true']:dir(rtl) .switch__thumb {
transform-origin: 0 50%;
}
/* Disabled and pending switches neither stretch nor change tone. */
.switch__track:disabled .switch__thumb,
.switch__track[aria-busy] .switch__thumb {
scale: 1;
}
/* A keyframe has no utility spelling. Under reduced motion the arc pulses instead of turning. */
.switch__spinner {
animation: switch-spin 0.8s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.switch__spinner {
animation: switch-pulse 1.6s ease-in-out infinite;
}
}
@keyframes switch-spin {
to {
rotate: 360deg;
}
}
@keyframes switch-pulse {
50% {
opacity: 0.4;
}
}
/*
* Forced colours drop box-shadows and fills, which would erase the track and the thumb. Draw
* the track with a system border and paint the thumb in system colours.
*/
@media (forced-colors: active) {
.switch__track {
border: 1px solid ButtonText;
}
.switch__thumb {
forced-color-adjust: none;
background: ButtonText;
color: ButtonFace;
}
.switch__track[aria-checked='true'] {
background: Highlight;
}
.switch__track[aria-checked='true'] .switch__thumb {
background: HighlightText;
color: Highlight;
}
.switch__track:disabled .switch__thumb {
background: GrayText;
}
}
</style>
Usage#
On this pagePass a label and bind checked (or pass checked with onCheckedChange). It renders a settings row: the label and optional description, and a role="switch" button that flips on click, Space, Enter or a click on the label. Return a promise from onCheckedChange to save the change; the switch waits, and moves back with a message if the save fails. It does not store the setting, call any service, or belong in a form that needs a submit step; use a checkbox there.
- Suggested location
src/lib/components/switch-01- Required props
label
Limitations
- It does not persist the setting; the consumer saves it in
onCheckedChangeand passes checked back. - For an async save, bind checked. With checked and
onCheckedChangealone, a failed save moves the switch back but the parent's own variable keeps whatever the callback set; set it back there too, or bind. - Without JavaScript the switch renders its state but cannot change, so it needs client-side hydration.
- While a save is pending the switch ignores clicks; a second change waits for the first to settle.
- The failure message shares the description's cell. With no description, or a message longer than the description, the row grows while the message shows.
- With name set, the hidden input posts only while the switch is on and enabled, the way a checkbox does; an off switch posts nothing.
- A blank label throws when the component is created, so an unnamed switch fails in development instead of shipping.
Example
<script lang="ts">
import Switch from '$lib/components/switch-01/Switch.svelte';
let digest = $state(false);
async function saveMentions(on: boolean) {
const res = await fetch('/api/settings/mentions', {
method: 'PUT',
body: JSON.stringify({ on })
});
return res.ok ? { ok: true } : { ok: false, message: 'Couldn’t save. Try again.' };
}
</script>
<div class="grid max-w-lg gap-4">
<Switch
label="Mentions and replies"
description="Email me when someone mentions me or replies on a card I follow."
checked
onCheckedChange={saveMentions}
/>
<Switch label="Weekly digest" bind:checked={digest} />
</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.
Switch#
A switch is for a setting that changes the moment it is flipped. If the choice only applies when a form is submitted, use a checkbox instead.
Saving a change#
onCheckedChange receives the new state. Return nothing for a local setting. Return a promise
to save it: the switch moves at once, shows a spinner in the thumb, and ignores clicks until the
promise settles.
async function save(on: boolean) {
const res = await fetch('/api/settings/digest', { method: 'PUT', body: JSON.stringify({ on }) });
if (res.ok) return { ok: true };
return { ok: false, message: 'Couldn’t save, so the weekly digest is still off. Try again.' };
}If the promise resolves { ok: false } or rejects, the switch moves back and the message takes
the description's place until the next flip. Write it as what happened and what is still set,
and keep it no longer than the description so the row holds its height.
An async switch with no description has no line for the message to take, so the row grows by
one line when a save fails. Where that would move rows below, reserve the line through class:
<Switch label="Weekly digest" class="min-h-11" onCheckedChange={save} bind:checked={digest} />Settings rows#
The component is one row. A settings panel is a list of them with a hairline between rows:
<ul class="divide-y divide-black/8 rounded-xl px-5 ring-1 ring-black/8">
<li class="py-4"><Switch label="Mentions and replies" bind:checked={mentions} /></li>
<li class="py-4"><Switch label="Weekly digest" bind:checked={digest} /></li>
</ul>Forms#
Set name and a hidden input posts value (default on) while the switch is on and enabled.
An off switch posts nothing, as a checkbox does, so treat a missing field as off.
Props and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
checked | boolean | No | false | Current state, rendered as aria-checked. $bindable: use bind:checked, or pass it with onCheckedChange. |
label | string | Yes | None | The visible label and accessible name. Name the setting ('Weekly digest'), not the action, and keep it the same in both states. Must not be blank. |
description | string | No | None | Help text under the label, wired as the switch's aria-describedby. |
onCheckedChange | (checked: boolean) => void | Promise<{ ok: boolean; message?: string }> | No | None | Called with the new state after every flip, never while disabled or pending. Return a promise to save: the switch shows pending until it settles and moves back with the message if it resolves { ok: false }, rejects or throws. Bind checked when saving, so a move back reaches the parent. |
errorMessage | string | No | 'That change was not saved, so the switch has moved back.' | Shown when a save fails without a message of its own. |
name | string | No | None | Form field name. When set, a hidden input posts value while the switch is on and enabled. |
value | string | No | 'on' | The value the hidden input posts while the switch is on. |
disabled | boolean | No | false | Native disabled. The state still shows, dimmed, and onCheckedChange is not called. |
size | 'sm' | 'md' | No | 'md' | sm is a 36 × 20 track with a 13 px label, md a 44 × 24 track with a 14 px label. Both have a target at least 44 px tall. |
labelPosition | 'start' | 'end' | No | 'start' | start puts the label first and the switch on the end edge, as in a settings row; end leads with the switch, as in a form or filter column. |
class | string | No | None | Extra classes on the row, for placement such as margins or grid placement. |
Customization#
On this pageChange the copy, size and label placement through props, and retone both states through seven --switch-* CSS variables. Hover tones are mixed from each state's colours, so they follow any accent.
- Accent:
--switch-accentis the on track and the focus ring;--switch-on-accentis the thumb when on, and the cross on the off thumb. Keep the accent at 3:1 against the page. - Off:
--switch-hairlinedraws the off track's outline and--switch-trackits thumb and faint wash. Keep both at 3:1 against the page, or the off state is hard to see. - Text:
--switch-inkis the label,--switch-mutedthe description,--switch-errorthe failure message and its mark. - Worked retone for a
#09090bband:--switch-accent:#fafafa;--switch-on-accent:#09090b;--switch-track:#a1a1aa;--switch-hairline:#a1a1aa;--switch-ink:#fafafa;--switch-muted:#a1a1aa;--switch-error:#fca5a5. - Layout: put several in a list with a hairline between rows and 16 px of padding, so each row reads as one setting with its switch on the end edge. Stacked without row padding, leave at least 24 px between switches so their 44 px targets do not overlap. Use
labelPosition="end" in a narrow form or filter column. - Saving: return a promise from
onCheckedChangethat resolves{ ok: true }once saved, or{ ok: false, message }with a sentence that says what happened and what is still set. - Behaviour: if the choice is only applied when a form is submitted, use a checkbox; a switch promises the change happens now.
Public CSS variables
| Variable | Token |
|---|---|
--switch-accent | accent |
--switch-on-accent | onAccent |
--switch-ink | ink |
--switch-muted | muted |
--switch-track | track |
--switch-hairline | hairline |
--switch-error | error |
Accessibility#
On this page- Follows the WAI-ARIA APG switch pattern: a native <button type="button" role="switch"> with
aria-checked="true" or "false". Space and Enter toggle through the button's own activation. - A visible <label for> names the switch through
aria-labelledby, and clicking it toggles. The label stays the same in both states; assistive technology announces 'Weekly digest, switch, off'. - The description, and the failure message while it shows, are wired with
aria-describedby. - On and off differ by outline against fill, thumb position and a cross or check in the thumb, so the state does not rely on colour. The off outline and thumb default to
#71717a, 4.8:1 on white. - While a save is pending the switch keeps focus, sets
aria-busyandaria-disabled, and ignores clicks. The failure message is written into a persistent role="status" region, so it is announced politely. - Focus shows a two-pixel accent ring two pixels outside the pill. The track is 24 or 20 px tall, with an invisible box that makes the target at least 44 px tall; the label is a target too. Stack switches at least 24 px apart so neighbouring targets do not overlap.
- Under forced colours the track gets a system border and the thumb is painted in system colours. Under reduced motion the thumb snaps and the spinner pulses instead of turning.
- Disabled uses the native attribute, which removes the switch from the tab order. Say nearby why the setting is locked.
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 · 22 October 2026
Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.