Switch
Binary on/off on bits-ui Switch. The thumb rides a snappy spring between positions and collapses to an instant snap under reduced-motion — pair with a Label.
Install
import { Switch } from '$lib/components/ui/switch'; States
switch.svelte
<script lang="ts">
import { Switch as SwitchPrimitive } from 'bits-ui';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import { animate } from 'motion';
import { springs, prefersReducedMotion } from '$lib/motion';
let {
ref = $bindable(null),
class: className,
checked = $bindable(false),
size = 'default',
...restProps
}: WithoutChildrenOrChild<SwitchPrimitive.RootProps> & {
size?: 'sm' | 'default';
} = $props();
let thumbRef = $state<HTMLElement | null>(null);
// Motion.dev's `animate({ x })` expects a number or px string — `calc()`
// silently fails. Measure track + thumb at effect time and spring to a
// concrete pixel target so the thumb actually travels.
$effect(() => {
const el = thumbRef;
if (!el) return;
const track = el.parentElement;
if (!track) return;
const read = () => {
const trackWidth = track.clientWidth;
const thumbWidth = el.offsetWidth;
// `trackWidth` is the content box (border excluded). Travelling the thumb
// the full content width keeps the off/on gaps symmetric — both ends rest
// 1px (the track border) from the outer edge. An extra inset here would
// only shave the on-side, which read as lopsided padding.
const targetX = checked ? Math.max(0, trackWidth - thumbWidth) : 0;
const transition = prefersReducedMotion() ? { duration: 0 } : springs.snappy;
animate(el, { x: targetX }, transition);
};
read();
const observer = new ResizeObserver(read);
observer.observe(track);
return () => observer.disconnect();
});
</script>
<SwitchPrimitive.Root
bind:ref
bind:checked
data-slot="switch"
data-size={size}
class={cn(
'peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-[background-color,border-color,box-shadow] outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-(length:--ring-width) focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-(length:--ring-width) aria-invalid:ring-destructive/20 data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50 data-[size=default]:h-(--switch-track-h-default) data-[size=default]:w-(--switch-track-w-default) data-[size=sm]:h-(--switch-track-h-sm) data-[size=sm]:w-(--switch-track-w-sm) data-[state=checked]:bg-primary data-[state=unchecked]:border-input data-[state=unchecked]:bg-surface-2 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
className
)}
{...restProps}
>
<SwitchPrimitive.Thumb
bind:ref={thumbRef}
data-slot="switch-thumb"
class="pointer-events-none block rounded-full bg-card shadow-surface-2 ring-0 group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 dark:shadow-none dark:group-data-[state=checked]/switch:bg-primary-foreground dark:group-data-[state=unchecked]/switch:bg-foreground"
/>
</SwitchPrimitive.Root>
Sizes
switch.svelte
<script lang="ts">
import { Switch as SwitchPrimitive } from 'bits-ui';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import { animate } from 'motion';
import { springs, prefersReducedMotion } from '$lib/motion';
let {
ref = $bindable(null),
class: className,
checked = $bindable(false),
size = 'default',
...restProps
}: WithoutChildrenOrChild<SwitchPrimitive.RootProps> & {
size?: 'sm' | 'default';
} = $props();
let thumbRef = $state<HTMLElement | null>(null);
// Motion.dev's `animate({ x })` expects a number or px string — `calc()`
// silently fails. Measure track + thumb at effect time and spring to a
// concrete pixel target so the thumb actually travels.
$effect(() => {
const el = thumbRef;
if (!el) return;
const track = el.parentElement;
if (!track) return;
const read = () => {
const trackWidth = track.clientWidth;
const thumbWidth = el.offsetWidth;
// `trackWidth` is the content box (border excluded). Travelling the thumb
// the full content width keeps the off/on gaps symmetric — both ends rest
// 1px (the track border) from the outer edge. An extra inset here would
// only shave the on-side, which read as lopsided padding.
const targetX = checked ? Math.max(0, trackWidth - thumbWidth) : 0;
const transition = prefersReducedMotion() ? { duration: 0 } : springs.snappy;
animate(el, { x: targetX }, transition);
};
read();
const observer = new ResizeObserver(read);
observer.observe(track);
return () => observer.disconnect();
});
</script>
<SwitchPrimitive.Root
bind:ref
bind:checked
data-slot="switch"
data-size={size}
class={cn(
'peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-[background-color,border-color,box-shadow] outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-(length:--ring-width) focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-(length:--ring-width) aria-invalid:ring-destructive/20 data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50 data-[size=default]:h-(--switch-track-h-default) data-[size=default]:w-(--switch-track-w-default) data-[size=sm]:h-(--switch-track-h-sm) data-[size=sm]:w-(--switch-track-w-sm) data-[state=checked]:bg-primary data-[state=unchecked]:border-input data-[state=unchecked]:bg-surface-2 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
className
)}
{...restProps}
>
<SwitchPrimitive.Thumb
bind:ref={thumbRef}
data-slot="switch-thumb"
class="pointer-events-none block rounded-full bg-card shadow-surface-2 ring-0 group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 dark:shadow-none dark:group-data-[state=checked]/switch:bg-primary-foreground dark:group-data-[state=unchecked]/switch:bg-foreground"
/>
</SwitchPrimitive.Root>
Two-way bound
switch-bound.svelte
<script lang="ts">
import { Switch } from '$lib/components/ui/switch';
let checked = $state(false);
</script>
<label class="flex items-center gap-2">
<Switch bind:checked />
<span>{checked ? 'On' : 'Off'}</span>
</label>API reference
Inherits bits-ui Switch.Root props via spread. The thumb translateX is spring-animated — honors prefers-reduced-motion automatically.
| Prop | Type | Default | Description |
|---|---|---|---|
boolean (bindable) | false | Current on/off state. Two-way bindable. | |
'sm' | 'default' | 'default' | Controls track and thumb dimensions via the --switch-track-* tokens. | |
boolean | false | Disables the control and drops opacity to 50%. | |
(checked: boolean) => void | — | Fired when the checked state changes. | |
HTMLButtonElement | null | null | Two-way-bindable element reference. | |
string | — | Merged onto the root via tailwind-merge. |