Teseria
All elements

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

Terminal
npm i framer-motion

2. Files

Switch.tsx121 lines
"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.css71 lines
/* 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);
}
tokens.cssshared · add once
/*
 * 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

Demo.tsx
import { Switch } from "./Switch";
import "./Switch.css";

export default function Demo() {
  return <Switch />;
}