Installation
Run this in a project set up with shadcn/ui:
$ pnpm dlx shadcn@latest add https://uxelith.com/r/switch.jsonIt adds these packages for you:
clsxtailwind-mergemotionThe source lands in your components folder as
switch.tsx. It's yours: change anything.
Usage
import { Switch } from "@/components/switch"
export function Settings() {
return <Switch label="Email notifications" description="Weekly summary." defaultChecked />
}Source
One file, 205 lines, nothing hidden. Read it before you install it.
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface SwitchProps extends Omit<React.ComponentProps<"button">, "onChange" | "value"> {
checked?: boolean;
defaultChecked?: boolean;
onCheckedChange?: (checked: boolean) => void;
/** Visible label beside the switch. Without one, pass `aria-label`. */
label?: React.ReactNode;
/** Helper text under the label, linked with `aria-describedby`. */
description?: React.ReactNode;
size?: "sm" | "md";
/** Submit as a form field with this name ("on" when checked, like a checkbox). */
name?: string;
/** Shows a spinner in the thumb and ignores input, e.g. while a change saves. */
loading?: boolean;
/** Draws a check or a cross in the thumb, so state doesn't rely on colour. */
icons?: boolean;
}
const sizes = {
sm: { track: "h-5 w-9", thumb: 16, travel: 16, icon: "size-2.5" },
md: { track: "h-6 w-11", thumb: 20, travel: 20, icon: "size-3" },
} as const;
// Motion presets (ADR-010).
const SPRING_SNAPPY = { type: "spring", visualDuration: 0.2, bounce: 0 } as const;
const SPRING_SMOOTH = { type: "spring", visualDuration: 0.35, bounce: 0.15 } as const;
const STRETCH = 5;
const DRAG_START = 3;
/**
* An on/off switch with a springy thumb you can also drag, like iOS.
*
* - `role="switch"` with `aria-checked`; Space and Enter toggle it.
* - Pressing stretches the thumb toward where it will go; dragging it past
* halfway and letting go sets that side.
* - A visible `label` is a real `<label>`, so clicking it toggles too, and
* `description` is linked with `aria-describedby`.
* - `loading` sets `aria-busy`, shows a spinner and ignores input.
* - With `name`, a hidden input submits it with forms.
* - Colours: `--switch-on` (default `--primary`), `--switch-thumb` (white).
* No travel under reduced motion.
*/
export function Switch({
checked: checkedProp,
defaultChecked = false,
onCheckedChange,
label,
description,
size = "md",
name,
loading = false,
icons = false,
id: idProp,
disabled,
className,
onClick,
...props
}: SwitchProps) {
const generatedId = React.useId();
const id = idProp ?? generatedId;
const [uncontrolled, setUncontrolled] = React.useState(defaultChecked);
const checked = checkedProp ?? uncontrolled;
const [pressed, setPressed] = React.useState(false);
const [dragX, setDragX] = React.useState<number | null>(null);
const drag = React.useRef<{ startX: number; from: number; moved: boolean } | null>(null);
const suppressClick = React.useRef(false);
const reduceMotion = useReducedMotion();
const s = sizes[size];
const set = (next: boolean) => {
if (next === checked) return;
if (checkedProp === undefined) setUncontrolled(next);
onCheckedChange?.(next);
};
const restX = checked ? s.travel : 0;
const stretched = pressed && dragX === null && !reduceMotion;
const x = dragX ?? (stretched && checked ? restX - STRETCH : restX);
const inert = disabled || loading;
const button = (
<button
id={id}
type="button"
role="switch"
aria-checked={checked}
aria-busy={loading || undefined}
aria-describedby={description ? `${id}-description` : undefined}
disabled={disabled}
onClick={(event) => {
onClick?.(event);
if (suppressClick.current) {
suppressClick.current = false;
return;
}
if (event.defaultPrevented || loading) return;
set(!checked);
}}
onPointerDown={(event) => {
if (inert || event.button !== 0) return;
setPressed(true);
event.currentTarget.setPointerCapture?.(event.pointerId);
drag.current = { startX: event.clientX, from: restX, moved: false };
}}
onPointerMove={(event) => {
const d = drag.current;
if (!d) return;
const dx = event.clientX - d.startX;
if (!d.moved && Math.abs(dx) < DRAG_START) return;
d.moved = true;
setDragX(Math.min(s.travel, Math.max(0, d.from + dx)));
}}
onPointerUp={() => {
const d = drag.current;
drag.current = null;
setPressed(false);
if (d?.moved && dragX !== null) {
suppressClick.current = true;
set(dragX > s.travel / 2);
}
setDragX(null);
}}
onPointerCancel={() => {
drag.current = null;
setPressed(false);
setDragX(null);
}}
className={cn(
"relative inline-flex shrink-0 cursor-pointer touch-none select-none items-center rounded-full border p-px outline-none",
"transition-[background-color,border-color,box-shadow] duration-200 motion-reduce:transition-none",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:cursor-not-allowed disabled:opacity-50 aria-busy:cursor-progress",
checked
? "border-transparent bg-[var(--switch-on,var(--primary))] shadow-[inset_0_1px_2px_rgb(0_0_0/0.15)]"
: "border-input bg-muted shadow-[inset_0_1px_2px_rgb(0_0_0/0.12)]",
s.track,
!label && className,
)}
{...props}
>
<motion.span
aria-hidden="true"
initial={false}
animate={{ x, width: stretched ? s.thumb + STRETCH : s.thumb }}
transition={dragX !== null || reduceMotion ? { duration: 0 } : pressed ? SPRING_SNAPPY : SPRING_SMOOTH}
style={{ height: s.thumb }}
className="flex items-center justify-center rounded-full bg-[var(--switch-thumb,white)] shadow-[0_1px_3px_rgb(0_0_0/0.3),0_0_0_0.5px_rgb(0_0_0/0.08)]"
>
{loading ? (
<svg viewBox="0 0 16 16" className={cn(s.icon, "animate-spin text-neutral-500 motion-reduce:animate-none")}>
<circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" strokeWidth="2" strokeDasharray="26 12" strokeLinecap="round" />
</svg>
) : (
icons && (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="2.25"
strokeLinecap="round"
strokeLinejoin="round"
className={cn(s.icon, checked ? "text-[var(--switch-on,var(--primary))]" : "text-neutral-400")}
>
{/* The icon on first render doesn't draw itself, so server and client agree. */}
<AnimatePresence initial={false} mode="popLayout">
<motion.path
key={checked ? "on" : "off"}
d={checked ? "M3.5 8.5 6.5 11.5 12.5 4.5" : "M4.5 4.5l7 7M11.5 4.5l-7 7"}
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
exit={{ opacity: 0, transition: { duration: 0 } }}
transition={reduceMotion ? { duration: 0 } : SPRING_SMOOTH}
/>
</AnimatePresence>
</svg>
)
)}
</motion.span>
{name && <input type="hidden" name={name} value={checked ? "on" : ""} disabled={disabled} />}
</button>
);
if (!label) return button;
return (
<div className={cn("flex items-start justify-between gap-4", className)}>
<div className="grid gap-0.5">
<label htmlFor={id} className={cn("text-sm font-medium", disabled ? "opacity-50" : "cursor-pointer")}>
{label}
</label>
{description && (
<p id={`${id}-description`} className="text-sm text-muted-foreground">
{description}
</p>
)}
</div>
{button}
</div>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| checked / defaultChecked | boolean | — / false | Controlled or uncontrolled state. |
| onCheckedChange | (checked: boolean) => void | — | Called with the new state. |
| label | ReactNode | — | Visible label; clicking it toggles. Without it, pass aria-label. |
| description | ReactNode | — | Helper text linked with aria-describedby. |
| size | "sm" | "md" | "md" | Track size. |
| name | string | — | Submit with forms as "on" when checked. |
| loading | boolean | false | Spinner in the thumb, aria-busy, ignores input — e.g. while a change saves. |
| icons | boolean | false | A check or cross in the thumb, so state doesn't rely on colour. |
| --switch-on | CSS variable | --primary | Track colour when on. |
Accessibility
- role="switch" with aria-checked; Space and Enter toggle it.
- The visible label is a real <label> for the switch.
- Description is linked with aria-describedby.
- loading sets aria-busy and ignores input.
- icons show state without relying on colour.
- No travel under prefers-reduced-motion.
Changelog
- v1.0.0Latest
First release.