Spring switch
The switch thumb stretches as it travels, overshoots a touch and settles, while the track fades to the on colour.
It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.
Demo.tsx
"use client";
import { useId, useState } from "react";
import styles from "./demo.module.css";
const SETTINGS = [
{ label: "Daily digest", hint: "A summary every morning at 8:00", on: true },
{ label: "Booking alerts", hint: "Push when a guest books", on: false },
{ label: "Auto reply", hint: "While you are away", on: false },
];
function Row({ label, hint, initial }: { label: string; hint: string; initial: boolean }) {
const [on, setOn] = useState(initial);
const [moved, setMoved] = useState(false);
const id = useId();
return (
<li className={styles.row}>
<span className={styles.text}>
<span id={id} className={styles.label}>{label}</span>
<span className={styles.hint}>{hint}</span>
</span>
<button
type="button"
role="switch"
aria-checked={on}
aria-labelledby={id}
className={`${styles.switch} ${moved ? (on ? styles.toOn : styles.toOff) : ""}`}
onClick={() => {
setOn(!on);
setMoved(true);
}}
>
<span className={styles.thumb} />
</button>
</li>
);
}
export default function Demo() {
return (
<div className={styles.root}>
<ul className={styles.card}>
{SETTINGS.map((s) => (
<Row key={s.label} label={s.label} hint={s.hint} initial={s.on} />
))}
</ul>
</div>
);
}
demo.module.css
.root { width: 100%; height: 100%; display: grid; place-items: center; padding: 16px; }
.card { list-style: none; margin: 0; padding: 4px 18px; width: min(100%, 380px); background: var(--white); border: 1px solid var(--line); border-radius: 16px; }
.row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 0; }
.row + .row { border-top: 1px solid var(--line); }
.text { display: grid; gap: 2px; min-width: 0; }
.label { font-size: 15px; font-weight: 600; }
.hint { font-size: 13px; color: var(--muted); }
.switch {
position: relative; flex: none; width: 52px; height: 30px; padding: 0; border: 0; border-radius: 999px;
background: var(--line); cursor: pointer; -webkit-tap-highlight-color: transparent;
}
.switch::before { content: ""; position: absolute; inset: 0; border-radius: inherit; background: var(--accent-text); opacity: 0; transition: opacity .3s var(--out); }
.switch::after { content: ""; position: absolute; inset: -8px -4px; }
.switch[aria-checked="true"]::before { opacity: 1; }
.thumb {
position: absolute; top: 3px; left: 3px; width: 24px; height: 24px; border-radius: 999px;
background: var(--white); box-shadow: 0 1px 3px color-mix(in srgb, var(--ink) 30%, transparent);
}
.switch[aria-checked="true"] .thumb { translate: 22px 0; }
.toOn .thumb { transform-origin: right center; animation: toOn .55s var(--out); }
.toOff .thumb { transform-origin: left center; animation: toOff .55s var(--out); }
@keyframes toOn {
0% { translate: 0 0; scale: 1 1; }
30% { scale: 1.3 .88; }
65% { translate: 24px 0; scale: .94 1.05; }
100% { translate: 22px 0; scale: 1 1; }
}
@keyframes toOff {
0% { translate: 22px 0; scale: 1 1; }
30% { scale: 1.3 .88; }
65% { translate: -2px 0; scale: .94 1.05; }
100% { translate: 0 0; scale: 1 1; }
}
@media (prefers-reduced-motion: reduce) {
.toOn .thumb, .toOff .thumb { animation: none; }
}
Use it for
- Settings lists with instant on and off preferences
- Feature flags and notification toggles
Avoid
- Choices that need a Save step, where a checkbox is clearer
- Long lists of switches toggled in bulk, where the bounce gets busy
How it works
The resting position of the thumb is set with the translate property from aria-checked. After the first click a direction class runs a keyframe that moves the thumb past its end by 2px and back while scaling it to 1.3 by .88, anchored on its trailing side, so it reads as a squash and stretch. Each direction has its own keyframe name so rapid toggles restart the animation. The track colour is a pseudo element whose opacity cross-fades.
Accessibility
Each switch is a real button with role switch and aria-checked, labelled by its visible text. An invisible pseudo element extends the hit area to 44px tall. Reduced motion drops the keyframes so the thumb simply moves.