Turn a setting on or off.
Use a switch for a setting that takes effect as soon as it changes, like Airplane mode or a notification preference. Keep its label the same in both states: the label says what the setting is, and the switch shows whether it's on.
<script lang="ts">
import { Switch, SwitchThumb } from "@ui-sdk/svelte/switch"
</script>
<label data-slot="label">
Airplane mode
<Switch>
<SwitchThumb />
</Switch>
</label>Installation
npm install @ui-sdk/svelte @ui-sdk/coreComposition
Use the following composition to build a Switch:
Switch
└── SwitchThumbWrap the switch and its text in a label. The thumb stays mounted and gets the switch's state, so CSS can slide it from one end to the other.
Usage
Import the parts and put them together:
<script lang="ts">
import { Switch, SwitchThumb } from "@ui-sdk/svelte/switch"
</script>
<label>
Airplane mode
<Switch>
<SwitchThumb />
</Switch>
</label>Description
Use FieldLabel and FieldDescription when a setting needs more explanation. The field connects both to the switch.
<script lang="ts">
import { Field, FieldDescription, FieldLabel } from "@ui-sdk/svelte/field"
import { Switch, SwitchThumb } from "@ui-sdk/svelte/switch"
</script>
<Field>
<div data-slot="stack">
<FieldLabel>Weekly digest</FieldLabel>
<FieldDescription>A summary of your week, every Monday.</FieldDescription>
</div>
<Switch>
<SwitchThumb />
</Switch>
</Field>Choice Card
A setting can fill a whole card. Here the label's ::after covers the card, so a click anywhere on it toggles the switch, and :has([data-checked]) marks the cards that are on.
<script lang="ts">
import { Field, FieldDescription, FieldLabel } from "@ui-sdk/svelte/field"
import { Switch, SwitchThumb } from "@ui-sdk/svelte/switch"
const settings = [
{
label: "Public profile",
description: "Anyone can see your name and photo.",
defaultChecked: false,
},
{
label: "Show activity status",
description: "Teammates can see when you're online.",
defaultChecked: true,
},
]
</script>
<div data-slot="stack">
{#each settings as setting (setting.label)}
<Field data-variant="card">
<div data-slot="stack">
<FieldLabel>{setting.label}</FieldLabel>
<FieldDescription>{setting.description}</FieldDescription>
</div>
<Switch defaultChecked={setting.defaultChecked}>
<SwitchThumb />
</Switch>
</Field>
{/each}
</div>Disabled
disabled prevents focus and changes, and leaves the switch out of form submissions. A disabled fieldset does the same.
<script lang="ts">
import { Switch, SwitchThumb } from "@ui-sdk/svelte/switch"
</script>
<div data-slot="stack">
<label data-slot="label">
Product updates
<Switch>
<SwitchThumb />
</Switch>
</label>
<label data-slot="label">
Security alerts
<Switch defaultChecked disabled>
<SwitchThumb />
</Switch>
</label>
</div>Read-Only
readOnly keeps the switch focusable and included in form submissions, while preventing the user from changing it. Use it for a setting managed somewhere else.
<script lang="ts">
import { Field, FieldDescription, FieldLabel } from "@ui-sdk/svelte/field"
import { Switch, SwitchThumb } from "@ui-sdk/svelte/switch"
</script>
<Field>
<div data-slot="stack">
<FieldLabel>Encrypt backups</FieldLabel>
<FieldDescription>Your organization manages this setting.</FieldDescription>
</div>
<Switch name="encrypt" defaultChecked readOnly>
<SwitchThumb />
</Switch>
</Field>Controlled
Bind bind:checked, or pass checked and handle onCheckedChange, to own the value. A controller from createSwitchController() changes it from anywhere, like the Toggle button below. See State and Events for the shared change callback.
<script lang="ts">
import { createSwitchController } from "@ui-sdk/core/switch"
import { Switch, SwitchThumb } from "@ui-sdk/svelte/switch"
import { useLog } from "./log"
const log = useLog()
const wifi = createSwitchController()
let checked = $state(true)
</script>
<div data-slot="stack">
<div data-slot="controls">
<button type="button" onclick={() => wifi.toggle()}>Toggle</button>
</div>
<label data-slot="label">
Wi-Fi
<Switch
bind:checked
onCheckedChange={(next, details) =>
log(`${next ? "On" : "Off"} (${details.reason})`)}
controller={wifi}
>
<SwitchThumb />
</Switch>
</label>
</div>Form
Set name to include the switch in a native form. A switch that's on submits its value, which defaults to "on"; a switch that's off submits nothing. Reset returns it to defaultChecked. Set form to a form's id to join it from elsewhere on the page.
<script lang="ts">
import { FieldLegend, FieldSet } from "@ui-sdk/svelte/field"
import { Switch, SwitchThumb } from "@ui-sdk/svelte/switch"
import { useLog } from "./log"
const log = useLog()
</script>
<form
data-slot="stack"
onsubmit={(event) => {
event.preventDefault()
const entries = [...new FormData(event.currentTarget)].map(
([name, value]) => `${name}=${value}`
)
log(entries.length ? `Submitted ${entries.join("&")}` : "Submitted nothing")
}}
>
<FieldSet>
<FieldLegend>Email notifications</FieldLegend>
<label data-slot="label">
Product updates
<Switch name="updates">
<SwitchThumb />
</Switch>
</label>
<label data-slot="label">
Security alerts
<Switch name="security" defaultChecked>
<SwitchThumb />
</Switch>
</label>
<label data-slot="label">
Weekly digest
<Switch name="digest" value="weekly" defaultChecked>
<SwitchThumb />
</Switch>
</label>
</FieldSet>
<div data-slot="row">
<button type="submit" data-variant="solid">Save</button>
<button type="reset">Reset</button>
</div>
</form>Validation
required blocks submission until the switch is on. In a Field, FieldError shows the message under the switch and connects it to the switch. Forms and Validation covers the shared field behavior.
<script lang="ts">
import {
Field,
FieldDescription,
FieldError,
FieldLabel,
} from "@ui-sdk/svelte/field"
import { Switch, SwitchThumb } from "@ui-sdk/svelte/switch"
import { useLog } from "./log"
const log = useLog()
</script>
<form
data-slot="stack"
onsubmit={(event) => {
event.preventDefault()
log(
`Submitted two-factor=${new FormData(event.currentTarget).get("two-factor")}`
)
}}
>
<Field>
<div data-slot="row">
<div data-slot="stack">
<FieldLabel>Two-factor authentication</FieldLabel>
<FieldDescription
>Required for admins of this workspace.</FieldDescription
>
</div>
<Switch name="two-factor" required>
<SwitchThumb />
</Switch>
</div>
<FieldError match="valueMissing">Turn this on to continue.</FieldError>
</Field>
<div data-slot="row">
<button type="submit" data-variant="solid">Continue</button>
</div>
</form>Saving on Toggle
To save a change before it shows, call details.cancel() in onCheckedChange to keep the current value while the save runs. When it finishes, call controller.setChecked(next, { reason: "imperative" }), and let that reason through in the callback so the saved value commits.
The example ignores presses while a save is running, and shows a spinner in the thumb. aria-busy tells assistive technology the switch is busy; the callback is what blocks another change.
<script lang="ts">
import { LoaderCircleIcon } from "@lucide/svelte"
import { createSwitchController } from "@ui-sdk/core/switch"
import { Switch, SwitchThumb } from "@ui-sdk/svelte/switch"
import { useLog } from "./log"
const save = (_checked: boolean) =>
new Promise((resolve) => setTimeout(resolve, 800))
const log = useLog()
const backups = createSwitchController()
let saving = $state(false)
</script>
<label data-slot="label">
Automatic backups
<Switch
controller={backups}
aria-busy={saving}
onCheckedChange={(next, details) => {
if (details.reason === "imperative") {
return
}
details.cancel()
if (saving) {
return
}
saving = true
log("Saving…")
save(next).then(() => {
backups.setChecked(next, { reason: "imperative" })
saving = false
log(`Saved: ${next ? "on" : "off"}`)
})
}}
>
<SwitchThumb>
{#if saving}<LoaderCircleIcon
data-slot="spinner"
aria-hidden="true"
/>{/if}
</SwitchThumb>
</Switch>
</label>Custom Rendering
The switch and thumb take a render snippet, which receives the props to spread and the part's state. Keep the part's props on the element so its role, label and keyboard behavior stay connected. See Parts and Composition for the rendering contract.
<script lang="ts">
import { MoonIcon, SunIcon } from "@lucide/svelte"
import { Switch, SwitchThumb } from "@ui-sdk/svelte/switch"
</script>
<label data-slot="label">
Dark mode
<Switch>
<SwitchThumb>
{#snippet render({ props, state })}
<span {...props}>
{#if state.checked}<MoonIcon aria-hidden="true" />{:else}<SunIcon
aria-hidden="true"
/>{/if}
</span>
{/snippet}
</SwitchThumb>
</Switch>
</label>Styling
Style the switch and its thumb with data-checked and data-unchecked, and use data-disabled, data-readonly and data-required for the rest. In an invalid field, the switch also gets data-invalid and aria-invalid; the thumb doesn't. Slide the thumb with translate on data-checked, and reverse it under dir="rtl".
Each part has a data-slot, such as switch, and its state attributes are listed under Data Attributes.
Data Attributes
Switch
[data-slot="switch"]
| Attribute | What it means |
|---|---|
data-checked | Present while on. |
data-disabled | Present when disabled, by its own prop or a <fieldset>. |
data-readonly | Present when read-only. |
data-required | Present when required. |
data-unchecked | Present while off. |
data-invalid | Present while a Field around it shows as invalid. The thumb doesn't get it. |
SwitchThumb
[data-slot="switch-thumb"]
| Attribute | What it means |
|---|---|
data-checked | Present while on. |
data-disabled | Present when disabled, by its own prop or a <fieldset>. |
data-readonly | Present when read-only. |
data-required | Present when required. |
data-unchecked | Present while off. |
API Reference
Props and callbacks are the same as below, as Svelte props. checked can be bound with bind:checked.
Switch
checked?boolean- Whether it's checked. Pass it to control the checkbox.
defaultChecked?boolean- Whether it's checked at first, when uncontrolled. A form reset returns to it.
onCheckedChange?((checked: boolean, details: ChangeDetails<CheckboxCheckedChangeReason>) => void)- Runs when a user or a form reset wants to change it.
details.reasonsays why, anddetails.cancel()stops it. disabled?boolean- Disables it like a native control: it can't be focused or changed, and isn't submitted.
readOnly?boolean- Keeps it focusable and submitted, but stops changes.
required?boolean- Blocks form submission until it's checked, with the browser's own message.
name?string- The name it submits under. Without one, it submits nothing.
value?string- What it submits while checked.
form?string- The id of a form to belong to, when it isn't inside that form.
controller?{ toggle: (details?: Details) => boolean; getChecked: () => boolean; setChecked: (checked: boolean, details?: Details) => boolean; focus: () => void; }- Toggles it from anywhere. Create one with
createSwitchController(). debug?boolean- Logs every state change with its reason to the console, in development builds.
State
What render and children snippets receive. The thumb's state, SwitchThumbState, is the same.
checkedboolean- Whether it's checked. False while indeterminate.
disabledboolean- Whether it's disabled, by itself or by a
<fieldset>. readOnlyboolean- Whether it's read-only: it stays focusable and submitted, but presses don't change it.
requiredboolean- Whether a form won't submit until it's checked. Indeterminate doesn't count.
Reasons
details.reason says why the switch turned on or off.
pressMouseEvent | KeyboardEvent- A single-element control was pressed: a checkbox, a switch or a toggle.
resetnone- A form reset.
restorenone- The browser restored state, like after going back, or a saved value was read back from storage.
imperativenone- A controller or
apicall, or a property set on a web component.
Controller
Create one with createSwitchController() and pass it as controller.
Every write method takes an optional { reason, event }, runs the change callback like a press, and returns false when the change is canceled.
toggle(details?)(details?: CheckboxChangeOptions | undefined) => boolean- Turns it on, or off.
getChecked()() => boolean- Whether it's on.
setChecked(checked, details?)(checked: boolean, details?: CheckboxChangeOptions | undefined) => boolean- Turns it on or off, as a user would.
focus()() => void- Moves focus to it.
Events
Callbacks get the new state first, then the details. A bound checked only updates when onCheckedChange wasn't canceled.
| Prop | On | Receives |
|---|---|---|
bind:checked | Switch | Whether the switch is on |
onCheckedChange | Switch | (checked, details): the new state, and the reason |
Accessibility
The switch follows the WAI-ARIA switch pattern. Switch renders a button with the switch role, and a hidden native checkbox that takes part in forms.
Keyboard
| Key | Does |
|---|---|
| Tab | Moves to the next switch |
| Space | Turns it on or off |
| Enter | Submits its form, and never toggles it |
Tested Behavior
Every line below is an automated test.
- Starts in its default state.
- Clicking toggles it.
- Clicking its label toggles it.
- Space toggles it.
- Enter submits the form and never toggles it.
- The thumb follows the state.
- The first Space reports keyboard.
- The first tap reports touch.
Runs in React, Vue and web components.
In forms:
- The form submits every checked control's name and value.
- Unchecked controls submit nothing.
- A checkbox outside the form joins it with the form attribute.
- Required controls block submission.
- A form reset returns every control to its default.
- A reset returns to the default value, not to nothing.
- A disabled fieldset disables the controls inside it.
- A read-only control still submits.
- A user's change fires native input and change events on the form.
Runs in React, Vue and web components.
Beyond the pattern's baseline:
- The first invalid control takes focus in document order.
- A later required field without an error part doesn't take focus from the first.
- Take part in the form with no hidden input. Web components only.
- Report validity like native controls. Web components only.
- Have live checked and value properties. Web components only.
- Are named by their labels. Web components only.
Runs in React, Vue and web components.
Screen readers hear the same thing in each tested framework, recorded by a virtual screen reader:
- Announcements while checking boxes with a keyboard.
- Announcements while choosing a radio with a keyboard.
Runs in React, Vue and web components.