Switch
A knob that stretches while held, follows a drag and settles on a spring; the fill fades in as it travels.
Code
1. Install
npm i framer-motion
2. Files
"use client";
// Switch: a knob that stretches while held, follows a drag and settles on a spring; the fill fades in as it travels.
import { useEffect, useId, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent, type ReactNode } from "react";
import { animate, motion, useMotionValue, useReducedMotion, useTransform } from "framer-motion";
export type SwitchCue = "enable" | "disable";
export type SwitchProps = {
/** On or off (controlled). */
checked?: boolean;
/** Initial state when uncontrolled. */
defaultChecked?: boolean;
/** Called with the new state. */
onChange?: (on: boolean) => void;
/** Text beside the switch. Clicking it flips the switch too. */
label?: ReactNode;
/** Stops all input and dims the switch. */
disabled?: boolean;
/** Track size. */
size?: "sm" | "md";
/** Sound hook: "enable" when switched on, "disable" when switched off. */
onSound?: (cue: SwitchCue) => void;
className?: string;
style?: CSSProperties;
};
const SIZES = { md: { width: 44, height: 26, knob: 20 }, sm: { width: 36, height: 20, knob: 16 } } as const;
const GLIDE = { type: "spring", stiffness: 380, damping: 34, mass: 0.8 } as const;
const RELEASE = { type: "spring", stiffness: 500, damping: 18, mass: 0.7 } as const;
type Grab = { fromX: number; knobAt: number; dragged: boolean };
export function Switch({ checked, defaultChecked = false, onChange, label, disabled, size = "md", onSound, className, style }: SwitchProps) {
const uid = useId();
const [own, setOwn] = useState(defaultChecked);
const isOn = checked ?? own;
const { width, height, knob } = SIZES[size];
const run = width - 6 - knob;
const calm = !!useReducedMotion();
const pos = useMotionValue(isOn ? run : 0);
const squeeze = useMotionValue(0);
const grab = useRef<Grab | null>(null);
const reach = useTransform(pos, [0, run], [0, 1]);
const stretchX = useTransform(squeeze, [0, 1], [1, 1.3]);
const stretchY = useTransform(squeeze, [0, 1], [1, 0.9]);
useEffect(() => {
const a = animate(pos, isOn ? run : 0, calm ? { duration: 0 } : GLIDE);
return () => a.stop();
}, [isOn, run, pos, calm]);
const commit = (next: boolean) => {
if (next === isOn) return;
onSound?.(next ? "enable" : "disable");
if (checked === undefined) setOwn(next);
onChange?.(next);
};
const release = () => animate(squeeze, 0, calm ? { duration: 0 } : RELEASE);
const onDown = (e: PointerEvent<HTMLButtonElement>) => {
if (disabled) return;
try {
e.currentTarget.setPointerCapture(e.pointerId);
} catch {}
grab.current = { fromX: e.clientX, knobAt: pos.get(), dragged: false };
animate(squeeze, 1, { duration: 0.16 });
};
const onMove = (e: PointerEvent<HTMLButtonElement>) => {
const g = grab.current;
if (!g) return;
const travelled = e.clientX - g.fromX;
if (Math.abs(travelled) > 4) g.dragged = true;
if (g.dragged) pos.set(Math.min(run + 4, Math.max(-4, g.knobAt + travelled)));
};
const onUp = () => {
const g = grab.current;
grab.current = null;
release();
if (!g) return;
if (!g.dragged) return commit(!isOn);
const lands = pos.get() > run / 2;
if (lands === isOn) animate(pos, isOn ? run : 0, calm ? { duration: 0 } : GLIDE);
commit(lands);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
commit(!isOn);
} else if (e.key === "ArrowRight") commit(true);
else if (e.key === "ArrowLeft") commit(false);
};
return (
<label className={`tsw-root${className ? ` ${className}` : ""}`} data-size={size} data-disabled={disabled || undefined} htmlFor={uid} style={style}>
<motion.button
id={uid}
type="button"
role="switch"
aria-checked={isOn}
disabled={disabled}
className="tsw-track"
style={{ width, height }}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={() => {
grab.current = null;
release();
animate(pos, isOn ? run : 0, calm ? { duration: 0 } : GLIDE);
}}
onClick={(e) => e.detail === 0 && !grab.current && commit(!isOn)}
onKeyDown={onKey}
>
<motion.span className="tsw-fill" aria-hidden style={{ opacity: reach }} />
<motion.span className="tsw-knob" style={{ width: knob, height: knob, x: pos, scaleX: stretchX, scaleY: stretchY, originX: isOn ? 1 : 0 }} />
</motion.button>
{label && <span className="tsw-label">{label}</span>}
</label>
);
}
/* Switch. Prefix tsw-. */
.tsw-root, .tsw-root * { box-sizing: border-box; }
.tsw-root { font-family: var(--ts-font, system-ui, sans-serif); }
:where(.tsw-root) button {
background: none;
border: 0;
color: inherit;
font: inherit;
margin: 0;
padding: 0;
}
:where(.tsw-root) :focus-visible { outline: 2px solid var(--ts-focus, #3446eb); outline-offset: 2px; }
.tsw-root {
-webkit-tap-highlight-color: transparent;
align-items: center;
cursor: pointer;
display: inline-flex;
gap: 10px;
user-select: none;
}
.tsw-root[data-disabled] {
opacity: 0.45;
cursor: default;
}
.tsw-track {
background: rgba(var(--ts-fg-rgb), 0.18);
border: 0;
border-radius: 999px;
cursor: inherit;
display: block;
overflow: hidden;
padding: 3px;
position: relative;
touch-action: none;
}
.tsw-fill {
background: var(--ts-fg);
border-radius: inherit;
inset: 0;
pointer-events: none;
position: absolute;
}
.tsw-knob {
position: relative;
}
.tsw-track:focus-visible {
outline: 0;
box-shadow: 0 0 0 2px var(--ts-page), 0 0 0 4px var(--ts-accent);
}
.tsw-knob {
background: var(--ts-raised);
border-radius: 999px;
box-shadow: var(--ts-edge);
display: block;
}
[data-theme="dark"] .tsw-knob {
background: var(--ts-fg);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .tsw-knob {
background: var(--ts-fg);
}
}
.tsw-track[aria-checked="true"] .tsw-knob {
background: var(--ts-raised);
}
.tsw-label {
font-size: 14px;
color: var(--ts-fg);
}
/*
* Teseria tokens — the only colours, fonts and corner radii a block reads.
*
* Every name sits under --ts- so it never collides with your app's own variables. Include this file once, or point
* the names at your theme (e.g. --ts-accent: var(--brand)). Light by default; dark follows [data-theme="dark"] on
* any ancestor, or the OS setting unless [data-theme="light"] is set.
*
* Surfaces page → raised (cards, popovers) · sunken (wells, tracks) · tint-1..3 (neutral fills, light to deep)
* Text fg → secondary → tertiary → muted → faint · fg-inverse sits on an fg-coloured fill
* Lines border · edge (the box-shadow a filled surface uses instead of an outline: soft in light, nothing in
* dark; composes with other shadows)
* Signals accent · focus · success · warning · danger
* Corners controls are always pills · r-panel (dialogs, sheets, cards) · r-pop (menus, popovers, toasts) · r-inner
* (thumbnails, inner cards); a row inside a panel uses the panel radius minus the panel padding
*/
:root {
--ts-font: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--ts-r-panel: 24px;
--ts-r-pop: 18px;
--ts-r-inner: 12px;
--ts-page: #ffffff;
--ts-raised: #ffffff;
--ts-sunken: #f3f2f0;
--ts-tint-1: #f5f4f2;
--ts-tint-2: #ecebe8;
--ts-tint-3: #e3e1dd;
--ts-fg: #161514;
--ts-fg-rgb: 22, 21, 20;
--ts-fg-secondary: #3a3836;
--ts-fg-tertiary: #585552;
--ts-fg-muted: #6d6a66;
--ts-fg-faint: #89857f;
--ts-fg-inverse: #ffffff;
--ts-border: rgba(22, 21, 20, 0.1);
--ts-edge: 0 0 0 1px rgba(22, 21, 20, 0.07), 0 1px 2px rgba(22, 21, 20, 0.05);
--ts-accent: #3446eb;
--ts-focus: #3446eb;
--ts-success: #13804a;
--ts-warning: #a9650b;
--ts-danger: #df474e;
}
:root[data-theme="dark"] {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
}
3. Usage
import { Switch } from "./Switch";
import "./Switch.css";
export default function Demo() {
return <Switch />;
}