Skip to content
← Components

Switch

00 layers

Source

switch.tsx
"use client";

import { useEffect, useEffectEvent, useLayoutEffect, useRef, useState, type CSSProperties, type MouseEvent, type PointerEvent } from "react";
import { focusQuietly } from "@/design-system";
import { hostTransport, play } from "@/lib/sound";

/*
 * A settings toggle as an over-centre slide switch, a recorder's HOLD switch
 * grown up: a knurled cap in a recessed track, an orange stripe the cap
 * uncovers as it slides on, a light beside it and an engraved label.
 *
 * The feel is a small integrator, not a transition. Under a finger the cap
 * lags further and further behind it toward the centre, the spring pushing
 * back; at the centre it snaps through, clicks and lands on the far stop on
 * its own, while the finger is still down. Let go short of the centre and the
 * spring takes it home, unless the finger was moving fast enough to carry it
 * over: a flick decides by velocity, not just position. Past the ends it gives
 * a little and bumps. A tap, Space or Enter flicks it. The cap is drawn from
 * a CSS variable the integrator writes; React only hears about the snap.
 *
 * With `name` it carries its state in a hidden checkbox, for forms.
 */

/* The track, in em. The cap is wider than its travel, so it always covers the stripe's far end. */
const TRACK_W = 5.8;
const TRACK_H = 2.35;
const INSET = 0.23;
const CAP_W = 2.9;
const TRAVEL = TRACK_W - 2 * INSET - CAP_W;

/* The mechanism, in travel (0 is off, 1 is on) and seconds. */
const BREAKOUT = 0.03; // the finger moves this far before the preloaded cap does
const SNAP_AT = 0.72; // how far the finger has gone when the cap reaches the centre and snaps over
const BEND = SNAP_AT - 0.5 - BREAKOUT; // how far behind the finger the cap has fallen by then
const RUBBER = 0.1; // the most the cap gives past an end
const FOLLOW = { k: 2200, c: 84 }; // the cap on the finger: stiff, just under critically damped
const SPRING = { preload: 40, peak: 260 }; // the over-centre spring, toward the nearer end: strongest just past the centre
const STOP = { k: 16000, c: 180 }; // the end stops: hard, with a little give
const AIR = 6;
const FLICK = 0.15; // s: on release, the cap is projected this far ahead at the finger's speed
const FLICK_MIN = 0.8; // travel per second toward the other side before a release counts as a flick
const FLICK_SPEED = 12; // travel per second of the finger a tap, a key or a change from outside stands in for
const STEP = 1 / 480;
const TAP = { px: 5, ms: 500 };

/** Where the cap sits for a finger `q` travel from its home end: further and further behind it toward the centre, a little past the end. */
function capFor(q: number) {
  if (q < 0) return -RUBBER * (1 - Math.exp(q / (2.5 * RUBBER)));
  return Math.max(0, q - BREAKOUT - BEND * (q / SNAP_AT) ** 2);
}

/** The finger that would hold the cap at `r`, so a hand can take it mid-flight without a jump. */
function fingerFor(r: number) {
  if (r < 0) return 2.5 * RUBBER * Math.log(1 + Math.max(-0.99 * RUBBER, r) / RUBBER);
  if (r < 1e-3) return 0;
  const a = BEND / SNAP_AT ** 2;
  return (1 - Math.sqrt(Math.max(0, 1 - 4 * a * (BREAKOUT + r)))) / (2 * a);
}

const cx = (...parts: (string | false | undefined)[]) => parts.filter(Boolean).join(" ");
const reducedMotion = () => matchMedia("(prefers-reduced-motion: reduce)").matches;

type SwitchProps = {
  label: string;
  checked: boolean;
  onCheckedChange: (checked: boolean) => void;
  /** Carries the state in a hidden checkbox, for forms. */
  name?: string;
  disabled?: boolean;
  className?: string;
};

type Engine = {
  grab: (x: number, travelPx: number, t: number) => void;
  move: (x: number, t: number) => void;
  release: (t: number, cancelled: boolean) => void;
  /** Flicks it to the other side; `user` when a hand or a key asked. */
  flip: (user: boolean) => void;
  /** Puts it on a side at once (reduced motion). */
  place: (on: boolean, user: boolean) => void;
  /** The side it is on, or on its way to. */
  readonly on: boolean;
};

export function Switch({ label, checked, onCheckedChange, name, disabled = false, className }: SwitchProps) {
  const rootRef = useRef<HTMLButtonElement>(null);
  const engine = useRef<Engine | null>(null);
  const own = useRef<boolean | null>(null); // a state this switch just reported, ahead of the render that confirms it
  const placed = useRef(false);
  const [initial] = useState(checked);
  const props = useRef({ onCheckedChange });
  useLayoutEffect(() => {
    props.current = { onCheckedChange };
  });

  // The integrator: one cap, a finger (a hand's, or a stand-in for a tap, a key or a change from outside),
  // the over-centre spring and the stops. It writes --x every frame and tells React only at the snap.
  useLayoutEffect(() => {
    const root = rootRef.current!;
    let x = initial ? 1 : 0;
    let v = 0;
    let side: 0 | 1 = initial ? 1 : 0; // the end the spring is pushing toward
    let user = false; // whoever moved it last was the viewer, not the page
    let time = 0; // the integrator's own clock, s
    let raf = 0;
    let last = 0;
    type Hand = { anchorX: number; anchorP: number; lastX: number; startX: number; startT: number; travelPx: number; samples: [number, number][]; moved: boolean; flying: number; bumped: boolean };
    let hand: Hand | null = null;
    let ghost: { t0: number; q0: number; speed: number } | null = null;

    const rel = (p: number) => (side ? 1 - p : p); // travel from the home end, toward the other side
    const abs = (r: number) => (side ? 1 - r : r);
    const draw = () => root.style.setProperty("--x", x.toFixed(4));
    const light = () => {
      if (side) root.dataset.on = "";
      else delete root.dataset.on;
    };

    /** Where the finger wants the cap, or null when the cap is free. */
    function target() {
      if (ghost) return abs(capFor(ghost.q0 + ghost.speed * (time - ghost.t0)));
      if (!hand || hand.flying) return null;
      const q = rel(hand.anchorP + (hand.lastX - hand.anchorX) / hand.travelPx);
      // Pulled past the end: it gives, and bumps the stop once each time.
      if (q < -0.06 && !hand.bumped) {
        hand.bumped = true;
        play("bump", { gain: 0.5 });
      } else if (q > -0.02) hand.bumped = false;
      return abs(capFor(q));
    }

    /** Through the centre: the spring takes it to the other side. */
    function snap() {
      side = side ? 0 : 1;
      light();
      if (user) {
        play("toggle");
        navigator.vibrate?.(8);
        own.current = side === 1;
        props.current.onCheckedChange(side === 1);
      } else if (hostTransport(root) === "play") play("toggle");
      ghost = null;
      // The cap ran out from under the finger: it flies to the stop, then the finger has it again, from there.
      if (hand) {
        hand.flying = time;
        hand.anchorX = hand.lastX;
        hand.anchorP = side;
      }
    }

    function step(h: number) {
      time += h;
      const to = target();
      let f: number;
      if (to !== null) f = FOLLOW.k * (to - x) - FOLLOW.c * v;
      else {
        const toward = side ? 1 : -1;
        f = toward * (SPRING.preload + SPRING.peak * Math.max(0, 1 - Math.abs(2 * x - 1))) - AIR * v;
        if (x > 1) f -= STOP.k * (x - 1) + STOP.c * v;
        else if (x < 0) f -= STOP.k * x + STOP.c * v;
      }
      v += f * h;
      x += v * h;
      if (rel(x) >= 0.5) snap();
      // Landed: the finger takes it again.
      if (hand?.flying && ((rel(x) < 0.02 && Math.abs(v) < 0.6) || time - hand.flying > 0.25)) hand.flying = 0;
    }

    function frame(now: number) {
      let dt = Math.min(0.05, last ? (now - last) / 1000 : STEP);
      last = now;
      while (dt > 1e-9) {
        const h = Math.min(STEP, dt);
        step(h);
        dt -= h;
      }
      const rest = side + (side ? 1 : -1) * (SPRING.preload / STOP.k);
      if (!hand && !ghost && Math.abs(v) < 0.005 && Math.abs(x - rest) < 0.002) {
        x = side;
        v = 0;
        draw();
        raf = 0;
        last = 0;
        return;
      }
      draw();
      raf = requestAnimationFrame(frame);
    }
    const run = () => {
      if (!raf) raf = requestAnimationFrame(frame);
    };

    engine.current = {
      get on() {
        return ghost ? side === 0 : side === 1;
      },
      grab(clientX, travelPx, t) {
        user = true;
        ghost = null;
        const p = side ? 1 - fingerFor(rel(x)) : fingerFor(rel(x));
        hand = { anchorX: clientX, anchorP: p, lastX: clientX, startX: clientX, startT: t, travelPx, samples: [[t, clientX]], moved: false, flying: 0, bumped: false };
        run();
      },
      move(clientX, t) {
        if (!hand) return;
        hand.lastX = clientX;
        hand.samples.push([t, clientX]);
        while (hand.samples.length > 2 && t - hand.samples[0][0] > 100) hand.samples.shift();
        if (Math.abs(clientX - hand.startX) > TAP.px) hand.moved = true;
        run();
      },
      release(t, cancelled) {
        const h = hand;
        if (!h) return;
        hand = null;
        if (cancelled || h.flying) return run();
        if (!h.moved && t - h.startT < TAP.ms) return this.flip(true);
        // A flick: project the cap ahead at the finger's speed; if that carries it over the centre, it goes.
        const [t0, x0] = h.samples[0];
        const speed = t > t0 + 8 ? ((h.lastX - x0) / h.travelPx / (t - t0)) * 1000 : 0;
        const toward = side ? -speed : speed;
        if (toward > FLICK_MIN && rel(x) + toward * FLICK >= 0.5) ghost = { t0: time, q0: fingerFor(Math.max(0, rel(x))), speed: Math.max(FLICK_SPEED, toward) };
        run();
      },
      flip(byUser) {
        user = byUser;
        // Flipped again before it reached the centre: the stand-in finger lets go and the spring takes it home.
        if (ghost) ghost = null;
        else if (reducedMotion()) return this.place(side === 0, byUser);
        else ghost = { t0: time, q0: fingerFor(Math.max(0, rel(x))), speed: FLICK_SPEED };
        run();
      },
      place(on, byUser) {
        if ((side === 1) === on) return;
        user = byUser;
        ghost = null;
        x = on ? 1 : 0;
        v = 0;
        snap();
        draw();
      },
    };
    return () => cancelAnimationFrame(raf);
  }, [initial]);

  // A new state from outside: the cap is flicked over to match. The switch's own reports are already there.
  useLayoutEffect(() => {
    const e = engine.current!;
    if (!placed.current) {
      placed.current = true;
      return;
    }
    const ours = own.current === checked;
    own.current = null;
    if (ours || e.on === checked) return;
    if (reducedMotion()) e.place(checked, false);
    else e.flip(false);
  }, [checked]);

  function onPointerDown(e: PointerEvent<HTMLButtonElement>) {
    if (e.button !== 0) return;
    const el = e.currentTarget;
    focusQuietly(el); // no ring until a key is pressed
    el.setPointerCapture(e.pointerId);
    const track = el.querySelector("[data-track]")!.getBoundingClientRect();
    engine.current?.grab(e.clientX, (track.width * TRAVEL) / TRACK_W, e.timeStamp);
  }

  // Keys and assistive tech arrive as clicks with no pointer behind them; the hand's own taps are handled above.
  function onClick(e: MouseEvent<HTMLButtonElement>) {
    if (e.detail === 0) engine.current?.flip(true);
  }

  return (
    <div className={cx("relative", className)}>
      <button
        ref={rootRef}
        type="button"
        role="switch"
        aria-checked={checked}
        disabled={disabled}
        data-on={initial || undefined}
        onPointerDown={onPointerDown}
        onPointerMove={(e) => engine.current?.move(e.clientX, e.timeStamp)}
        onPointerUp={(e) => engine.current?.release(e.timeStamp, false)}
        onPointerCancel={(e) => engine.current?.release(e.timeStamp, true)}
        onClick={onClick}
        className="group/switch flex min-h-[2.9em] w-full touch-pan-y select-none items-center gap-[0.75em] rounded-[0.7em] px-[0.15em] text-left outline-offset-2 disabled:cursor-not-allowed disabled:opacity-45"
        style={{ "--x": initial ? 1 : 0 } as CSSProperties}
      >
        <span data-part="lettering" className="min-w-0 flex-1 truncate text-[0.66em] font-semibold uppercase leading-none tracking-[0.14em] text-(--device-label) [text-shadow:var(--device-engrave)] drawn:mr-auto drawn:flex-initial">
          {label}
        </span>

        {/* The light: it comes on at the snap, not when React hears about it. Drawn, on is a disc of ink; off stays its hairline ring. */}
        <span aria-hidden data-part="well" className="grid size-[0.78em] shrink-0 place-items-center rounded-full bg-black/[0.05] shadow-(--device-recess) dark:bg-black/40">
          <span data-part="light" className="size-[0.44em] rounded-full bg-(--device-meter-off) transition-[background-color,box-shadow] duration-(--duration-exit) group-data-on/switch:bg-(--device-hold) group-data-on/switch:shadow-[0_0_0.5em_var(--device-hold)] group-data-on/switch:duration-0 drawn:group-data-on/switch:bg-(--device-draw-ink)!" />
        </span>

        {/* The track: recessed, its stripe uncovered by the cap as it slides on. Drawn, orange is the picked layer's, so the stripe is a hatch of ink in a hairline box. */}
        <span
          aria-hidden
          data-track
          data-part="well"
          className="relative shrink-0 rounded-[0.62em] bg-black/[0.09] shadow-(--device-recess) dark:bg-black/45"
          style={{ width: `${TRACK_W}em`, height: `${TRACK_H}em` }}
        >
          <span
            className="absolute rounded-[0.42em] bg-(--device-hold) bg-[linear-gradient(rgb(255_255_255/0.2),transparent_55%,rgb(0_0_0/0.08))] shadow-[inset_0_1px_2px_rgb(0_0_0/0.3),inset_0_0_0_1px_rgb(0_0_0/0.08)] drawn:border drawn:[background:repeating-linear-gradient(135deg,var(--device-draw-ink)_0_1px,transparent_1px_4px)]!"
            style={{ left: `${INSET}em`, top: `${INSET}em`, bottom: `${INSET}em`, width: `${TRAVEL + 0.3}em` }}
          />
          {/* The cap: a knurled key with a raised grip in the middle. Drawn, the grip is lines of ink. */}
          <span
            data-part="key"
            className="absolute rounded-[0.42em] [background:var(--device-key-face)] shadow-(--device-key-shadow) [--grip-hi:rgb(255_255_255/0.95)] [--grip:rgb(0_0_0/0.15)] dark:[--grip-hi:rgb(255_255_255/0.08)] dark:[--grip:rgb(0_0_0/0.6)]"
            style={{
              left: `${INSET}em`,
              top: `${INSET}em`,
              bottom: `${INSET}em`,
              width: `${CAP_W}em`,
              translate: `calc(var(--x) * ${TRAVEL}em) 0`,
            }}
          >
            <span
              className="absolute inset-x-[0.42em] inset-y-[0.3em] rounded-[0.14em] drawn:[background:repeating-linear-gradient(to_right,var(--device-draw-ink)_0_1px,transparent_1px_4px)]!"
              style={{ background: "repeating-linear-gradient(to right, var(--grip) 0 1px, var(--grip-hi) 1px 2px, transparent 2px 3.5px)" }}
            />
          </span>
        </span>
      </button>
      {name && <input type="checkbox" name={name} checked={checked} disabled={disabled} readOnly hidden />}
    </div>
  );
}

/* --- Demo: an input settings plate that tests its switches on power-up ------ */

type Setting = "noise" | "level" | "monitor";

const SETTINGS: { key: Setting; label: string; short: string }[] = [
  { key: "noise", label: "Noise reduction", short: "NR" },
  { key: "level", label: "Auto level", short: "AGC" },
  { key: "monitor", label: "Monitor", short: "MON" },
];
/** The self-test: each switch flicked on in turn, and Monitor back off. */
const SELF_TEST: [number, Setting, boolean][] = [
  [120, "noise", true],
  [360, "level", true],
  [600, "monitor", true],
  [1400, "monitor", false],
];
const TESTED = 2000; // ms: the screen reads Ready
const OFF: Record<Setting, boolean> = { noise: false, level: false, monitor: false };
const AFTER_TEST: Record<Setting, boolean> = { noise: true, level: true, monitor: false };
const chips = { idle: "Standby", test: "Self-test", ready: "Ready" } as const;

export default function Demo() {
  const [state, setState] = useState(OFF);
  const [phase, setPhase] = useState<"idle" | "test" | "ready">("idle");
  const rootRef = useRef<HTMLDivElement>(null);
  const timers = useRef<ReturnType<typeof setTimeout>[]>([]);

  // Power-up: the self-test flicks each switch in turn (its clicks follow the host's tape),
  // unless the viewer prefers less motion (then it just lands) or the host's tape is stopped.
  const powerUp = useEffectEvent(() => {
    if (reducedMotion()) {
      setState(AFTER_TEST);
      setPhase("ready");
      return;
    }
    if (hostTransport(rootRef.current) === "stop") return;
    setPhase("test");
    timers.current = [
      ...SELF_TEST.map(([at, key, on]) => setTimeout(() => setState((s) => ({ ...s, [key]: on })), at)),
      setTimeout(() => setPhase("ready"), TESTED),
    ];
  });
  useEffect(() => {
    const id = setTimeout(powerUp, 0);
    const pending = timers;
    return () => {
      clearTimeout(id);
      pending.current.forEach(clearTimeout);
    };
  }, []);

  // A hand on a switch ends the test where it stands.
  function stopTest() {
    if (phase !== "test") return;
    timers.current.forEach(clearTimeout);
    setPhase("ready");
  }

  return (
    <div ref={rootRef} onPointerDownCapture={stopTest} onKeyDownCapture={stopTest} className="@container w-full max-w-[360px] select-none">
      <div data-part="plate" className="relative isolate animate-enter overflow-hidden rounded-[1.25em] p-[0.9em] pb-[0.6em] text-[clamp(11px,4.2cqw,14px)] [background:var(--device-body)] shadow-[var(--device-body-edge),0_1px_2px_rgb(0_0_0/0.06),0_16px_32px_-18px_rgb(0_0_0/0.3)]">
        <div aria-hidden className="device-grain pointer-events-none absolute inset-0 -z-10 rounded-[inherit]" />

        {/* The input's screen: the self-test, then what is switched on. */}
        <div
          aria-hidden
          data-part="lcd"
          className="flex h-[3.3em] flex-col justify-between overflow-hidden rounded-[0.7em] px-[0.8em] pb-[0.5em] pt-[0.55em] text-(--device-lcd-ink) [background:var(--device-lcd)] shadow-(--device-lcd-edge)"
        >
          <div className="flex items-center gap-[0.5em]">
            <span data-part="chip" className="inline-flex shrink-0 items-center gap-[0.35em] rounded-[0.4em] bg-white px-[0.42em] py-[0.24em] text-black">
              {phase === "test" ? (
                <span className="size-[0.55em] animate-pulse rounded-full bg-(--device-rec) drawn:bg-(--device-rec)!" />
              ) : phase === "ready" ? (
                <span className="size-[0.55em] rounded-full bg-black drawn:bg-(--device-draw-ink)!" />
              ) : (
                <span className="size-[0.5em] rounded-[1px] bg-current drawn:bg-(--device-draw-ink)!" />
              )}
              <span className="text-[0.58em] font-semibold uppercase leading-none tracking-[0.02em]">{chips[phase]}</span>
            </span>
            <span className="ml-auto text-[0.66em] tabular-nums text-(--device-lcd-dim) drawn:text-(--device-draw-line)!">48 kHz · 24 bit</span>
          </div>
          <div className="flex items-baseline gap-[0.6em] leading-none">
            <span className="mr-auto truncate text-[1.05em] font-light tracking-[-0.01em]">Input 1</span>
            {SETTINGS.map((s) => (
              <span
                key={s.key}
                className={cx("text-[0.6em] font-semibold tracking-[0.06em] transition-colors duration-(--duration-exit)", state[s.key] ? "text-(--device-lcd-ink)" : "text-white/25 drawn:text-(--device-draw-line)!")}
              >
                {s.short}
              </span>
            ))}
          </div>
        </div>

        {/* The switches, each on its own engraved row. */}
        <div role="group" aria-label="Input" className="mt-[0.55em] flex flex-col">
          {SETTINGS.map((s, i) => (
            <div key={s.key}>
              {i > 0 && <div aria-hidden className="mx-[0.15em] h-px bg-black/[0.08] shadow-[0_1px_0_rgb(255_255_255/0.85)] dark:bg-black/60 dark:shadow-[0_1px_0_rgb(255_255_255/0.05)]" />}
              <Switch label={s.label} checked={state[s.key]} onCheckedChange={(on) => setState((prev) => ({ ...prev, [s.key]: on }))} />
            </div>
          ))}
        </div>
      </div>
      <p role="status" aria-live="polite" className="sr-only">
        {phase === "ready" ? "Input ready" : ""}
      </p>
    </div>
  );
}