Swish UI
All componentsButtons and controls Free

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.

Found on