Switch
An on/off toggle that applies immediately, with an accessible name and optional state labels.
Live demo
Switch is a role="switch" toggle for a setting that takes effect immediately, distinct from a checkbox that stages a value until a form submit. It renders a native <button> track with a sliding thumb, so pointer, Space, and Enter all flip it and the state lives in aria-checked.
An optional leading label and optional on-label/off-label state text make the toggle self-describing; clicking the label flips it like a native <label>, and spread stretches the row full width so a column of settings lines its tracks up at the far edge. It is form-associated: give it a name (and optional value) and it contributes to form data only while on. Two sizes: the default md and a compact sm.
When to use
How this component composes with the rest of the set.
Props
17 props, straight from the manifest.
| Prop | Type | Default | Bindings | Description |
|---|---|---|---|---|
aria-checked and the element's .checked. | ||||
false to keep the control out of sequential focus navigation, for an app driving selection from its own keyboard cursor. The host's own tabindex cannot express this, because focus lands on the inner control rather than the host, and setting it there from outside does not survive the next render. | ||||
:focus-visible ring (forced-colors outline included) without moving DOM focus. For a settings list driven from the app's own input layer, usually alongside focusable={false}. Controlled: move it in your own input handler. Changes no ARIA. | ||||
aria-disabled. | ||||
sm differs from the default. | ||||
end puts the control first and the label after it. | ||||
labelSide (with end the track leads and the label is end-aligned) and the writing direction; orientation only stands the track up, so a vertical spread switch keeps the same row. | ||||
aria-labelledby. | ||||
label. | ||||
label) when on. | ||||
label) when off. | ||||
value to form data only while on. | ||||
Events
What the component emits, and what rides along on event.detail. The name in the
first column is the one addEventListener takes.
| Event | Detail | Bindings | Description |
|---|---|---|---|
Appearance
Variants
square
Squared track and thumb corners.
vertical
Track stood on its end; down is off, up is on.
reverse
On/off direction flipped (on at the left, or the bottom when vertical).
label-end
Label after the control instead of before it.
spread
A full-width row: label and track at opposite edges.
Sizes
sm
Compact.
md
Default.
States
on
Toggle is on: the track fills with the chosen tone (accent by default) and the thumb slides to the end.
hover
Pointer over the track: overlay paints the hover tint.
active
Track pressed: overlay paints the press tint.
focus-visible
Keyboard focus: a token-colored ring, plus a transparent outline that becomes real in forced-colors mode.
cursor
App-owned keyboard cursor: cursor puts data-cursor on the track, which shares the focus-visible rule, so it draws the same ring and the same forced-colors outline while DOM focus stays where it is.
disabled
Non-interactive: muted track and thumb, overlay suppressed.
Anatomy
The named parts that make up the component. Created from script it renders a shadow root, where a ::part() handle is reachable from your own stylesheet and the class beside it is internal. Upgraded over server-rendered markup it renders light DOM, where the class is the handle.
switch
The row wrapper carrying the size, variant, and disabled classes and laying out label, track, and state text; inline by default, a full-width row under spread.
track
The interactive role="switch" button: the pill the thumb slides within.
thumb
The circular knob: a darkened cast of the track's tone, kept visible on and off.
label
The visible leading label that names the toggle and is referenced as its accessible name. It is a pointer target: a click flips the switch once, as a native <label> would, and does nothing while disabled. It takes no focus; the track stays the one focusable control. Under spread it grows to fill the row.
state
Optional trailing on/off state text; becomes the accessible name when no other label is given.
overlay
The pseudo-element behind the track that paints hover and active state tints.
Tokens & coverage
What the component consumes, checked live against what the algorithm produces.
Live coverage check against the xtyle-default register
(derive(xtyleDefault, { anchors }) →
coverComponent(manifest, register)). Every token this component
consumes must be a key the algorithm produces.
--accent--accent-2--accent-2-fg--accent-3--accent-3-fg--accent-4--accent-4-fg--accent-fg--black--black-fg--blue--blue-fg--border-normal--border-thick--border-thin--brown--brown-fg--cyan--cyan-fg--danger--danger-fg--duration-base--duration-fast--ease-emphasized--ease-standard--elevation-1--fg-0--fg-2--fg-disabled--font-sans--gray--gray-fg--green--green-fg--info--info-fg--leading-tight--line-strong--neutral--neutral-bg--neutral-fg--orange--orange-fg--pink--pink-fg--purple--purple-fg--radius-full--radius-sm--red--red-fg--ring--space-1--space-2--space-4--space-5--space-6--space-7--state-disabled--state-hover--state-press--success--success-fg--text-body--text-sm--warn--warn-fg--white--white-fg--yellow--yellow-fgAccessibility
Code
Labels, tones, shapes, and form association
A named toggle, on/off state text, the compact size, a toned switch, the square / vertical / reversed variants, a form-bound switch, and a disabled one.
<xtyle-switch label="Wi-Fi" checked></xtyle-switch>
<xtyle-switch label="Notifications" on-label="On" off-label="Off"></xtyle-switch>
<xtyle-switch size="sm" label="Compact mode"></xtyle-switch>
<xtyle-switch label="Alerts" tone="danger" checked></xtyle-switch>
<xtyle-switch label="Square" shape="square" checked></xtyle-switch>
<xtyle-switch label="Vertical" orientation="vertical" checked></xtyle-switch>
<xtyle-switch label="Reversed" reverse checked></xtyle-switch>
<xtyle-switch label="Label after" label-side="end" checked></xtyle-switch>
<xtyle-switch label="Sync" name="sync" value="enabled" checked></xtyle-switch>
<xtyle-switch label="Locked" disabled checked></xtyle-switch><script lang="ts">
import { Switch } from "@xtyle/svelte";
let wifi = $state(true);
let notify = $state(false);
</script>
<Switch label="Wi-Fi" bind:checked={wifi} />
<Switch label="Notifications" onLabel="On" offLabel="Off" bind:checked={notify} />
<Switch size="sm" label="Compact mode" />
<Switch label="Alerts" tone="danger" checked />
<Switch label="Square" shape="square" checked />
<Switch label="Vertical" orientation="vertical" checked />
<Switch label="Reversed" reverse checked />
<Switch label="Label after" labelSide="end" checked />
<Switch label="Sync" name="sync" value="enabled" bind:checked={wifi} />
<Switch label="Locked" disabled checked />---
import { Switch } from "@xtyle/astro";
---
<Switch label="Wi-Fi" checked />
<Switch label="Notifications" onLabel="On" offLabel="Off" />
<Switch size="sm" label="Compact mode" />
<Switch label="Alerts" tone="danger" checked />
<Switch label="Square" shape="square" checked />
<Switch label="Vertical" orientation="vertical" checked />
<Switch label="Reversed" reverse checked />
<Switch label="Label after" labelSide="end" checked />
<Switch label="Sync" name="sync" value="enabled" checked />
<Switch label="Locked" disabled checked />A spread settings column
spread stretches each switch across its row, so the labels sit at the start, the tracks line up at the end, and a click anywhere on a label flips its switch.
<xtyle-stack gap="3">
<xtyle-switch spread label="Email notifications" checked></xtyle-switch>
<xtyle-switch spread label="Reduced motion"></xtyle-switch>
<xtyle-switch spread label="Beta channel" disabled checked></xtyle-switch>
</xtyle-stack><script lang="ts">
import { Stack, Switch } from "@xtyle/svelte";
let email = $state(true);
let motion = $state(false);
</script>
<Stack gap={3}>
<Switch spread label="Email notifications" bind:checked={email} />
<Switch spread label="Reduced motion" bind:checked={motion} />
<Switch spread label="Beta channel" disabled checked />
</Stack>---
import { Stack, Switch } from "@xtyle/astro";
---
<Stack gap={3}>
<Switch spread label="Email notifications" checked />
<Switch spread label="Reduced motion" />
<Switch spread label="Beta channel" disabled checked />
</Stack>An app-owned keyboard cursor
A settings list driven from the app's own input layer rather than DOM focus: focusable={false} keeps the browser's tab stop out of it, and cursor on the current row draws the focus ring on its track. input stands in for the app's own action bindings.
<xtyle-stack gap="3" id="settings">
<xtyle-switch spread label="Subtitles" focusable="false" cursor checked></xtyle-switch>
<xtyle-switch spread label="Screen shake" focusable="false"></xtyle-switch>
<xtyle-switch spread label="Vibration" focusable="false" checked></xtyle-switch>
</xtyle-stack>
<script>
const rows = [...document.querySelectorAll("#settings xtyle-switch")];
let at = 0;
function moveCursor(step) {
rows[at].cursor = false;
at = (at + step + rows.length) % rows.length;
rows[at].cursor = true;
}
input.on("menu-up", () => moveCursor(-1));
input.on("menu-down", () => moveCursor(1));
input.on("confirm", () => (rows[at].checked = !rows[at].checked));
</script><script lang="ts">
import { Stack, Switch } from "@xtyle/svelte";
const rows = $state([
{ label: "Subtitles", on: true },
{ label: "Screen shake", on: false },
{ label: "Vibration", on: true },
]);
let at = $state(0);
input.on("menu-up", () => (at = (at - 1 + rows.length) % rows.length));
input.on("menu-down", () => (at = (at + 1) % rows.length));
input.on("confirm", () => (rows[at].on = !rows[at].on));
</script>
<Stack gap={3}>
{#each rows as row, i}
<Switch spread label={row.label} focusable={false} cursor={i === at} bind:checked={row.on} />
{/each}
</Stack>---
import { Stack, Switch } from "@xtyle/astro";
---
<Stack gap={3}>
<Switch spread label="Subtitles" focusable={false} cursor checked />
<Switch spread label="Screen shake" focusable={false} />
<Switch spread label="Vibration" focusable={false} checked />
</Stack>