Skip to content
← Components

Tape Reels

00 layers

Source

tape-reels.tsx
"use client";

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

/*
 * Progress as a tape transport: two reels behind smoked glass, the tape
 * running from one to the other over two guides and a head.
 *
 * The reels keep real proportions. Tape winds at a constant speed, so a
 * reel's radius grows with the square root of the tape on it, and its angle
 * comes out in closed form: θ = 2(r − r_hub) / k, where k is the tape's
 * thickness over π. The emptying reel visibly speeds up as it runs down, and
 * a jump in progress makes both reels whirl, as fast-forward does.
 *
 * Progress eases in on a critically damped spring (the reels have mass) and
 * is drawn by writing CSS variables and SVG attributes, never re-rendering
 * React. Reels turn by their gradients, not transforms. With `onSeek`, the
 * window is a slider: drag either reel round, or use the arrow keys.
 */

/* The window's geometry, in viewBox units (200 × 110). */
const W = 200;
const H = 110;
const R = 38; // a full pack
const HUB = 15;
const REELS = [
  { x: 52, y: 46 },
  { x: 148, y: 46 },
];
const GUIDES = [
  { x: 13, y: 96 },
  { x: 187, y: 96 },
];
const GUIDE_R = 5;
const TAPE_Y = GUIDES[0].y - GUIDE_R; // the run across the head
/** Tape thickness over π, chosen so a reel turns about eight times end to end. */
const K = (2 * (R - HUB)) / (2 * Math.PI * 8);
const LENGTH = (R * R - HUB * HUB) / K; // the whole tape, in viewBox units
const TICK_EVERY = Math.PI / 6; // a click every 30° of reel turned by hand

/** Pack radii and reel angles (radians) at progress p. */
function transport(p: number) {
  const span = R * R - HUB * HUB;
  const supply = Math.sqrt(HUB * HUB + (1 - p) * span);
  const takeUp = Math.sqrt(HUB * HUB + p * span);
  return { supply, takeUp, a: [(2 * (R - supply)) / K, (2 * (takeUp - HUB)) / K] };
}

/** Where tape leaving `from` meets a circle, on the side away from the head. */
function tangent(from: { x: number; y: number }, c: { x: number; y: number }, r: number, outer: -1 | 1) {
  const dx = from.x - c.x;
  const dy = from.y - c.y;
  const d = Math.hypot(dx, dy);
  const phi = Math.atan2(dy, dx);
  const alpha = Math.acos(Math.min(1, r / d));
  const t = phi + outer * alpha;
  return { x: c.x + r * Math.cos(t), y: c.y + r * Math.sin(t) };
}

const pct = (v: number) => `${((v / R) * 100).toFixed(2)}%`;

type TapeReelsProps = {
  /** 0 to 1. */
  progress: number;
  label: string;
  /** The head is writing: its light is on. */
  recording?: boolean;
  /** Makes the window a slider: called with the new progress as a hand turns a reel. */
  onSeek?: (progress: number) => void;
  /** The hand let go. */
  onSeekEnd?: () => void;
  className?: string;
};

export function TapeReels({ progress, label, recording = false, onSeek, onSeekEnd, className = "" }: TapeReelsProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const tapeRef = useRef<SVGPolylineElement>(null);
  const spring = useRef<ReturnType<typeof createSpring> | null>(null);
  const shown = useRef(progress);
  const drag = useRef<{ id: number; reel: 0 | 1; angle: number; turned: number; p: number } | null>(null);

  // The spring runs in thousandths, so it settles without a visible last step.
  useLayoutEffect(() => {
    const root = rootRef.current!;
    const reels = [...root.querySelectorAll<HTMLElement>("[data-reel]")];
    const draw = (permille: number) => {
      const p = Math.min(1, Math.max(0, permille / 1000));
      shown.current = p;
      const { supply, takeUp, a } = transport(p);
      [supply, takeUp].forEach((r, i) => {
        reels[i].style.setProperty("--r", pct(r));
        reels[i].style.setProperty("--a", `${((a[i] * 180) / Math.PI).toFixed(2)}deg`);
      });
      // The tape: off the supply pack, round the left guide, across the head, round the right guide, onto the take-up pack.
      const left = tangent({ x: GUIDES[0].x - GUIDE_R, y: GUIDES[0].y }, REELS[0], supply, 1);
      const right = tangent({ x: GUIDES[1].x + GUIDE_R, y: GUIDES[1].y }, REELS[1], takeUp, -1);
      tapeRef.current?.setAttribute(
        "points",
        `${left.x.toFixed(2)},${left.y.toFixed(2)} ${GUIDES[0].x - GUIDE_R},${GUIDES[0].y} ${GUIDES[0].x},${TAPE_Y} ${GUIDES[1].x},${TAPE_Y} ${GUIDES[1].x + GUIDE_R},${GUIDES[1].y} ${right.x.toFixed(2)},${right.y.toFixed(2)}`,
      );
    };
    const s = createSpring(shown.current * 1000, { stiffness: 140, damping: 24 }, draw);
    spring.current = s;
    draw(shown.current * 1000);
    return () => s.stop();
  }, []);

  useLayoutEffect(() => {
    const s = spring.current!;
    // A hand on a reel moves the tape itself; anything else eases in.
    if (drag.current || matchMedia("(prefers-reduced-motion: reduce)").matches) s.jump(progress * 1000);
    else s.set(progress * 1000);
  }, [progress]);

  /* --- Seeking ------------------------------------------------------------ */

  const seek = (p: number) => onSeek?.(Math.min(1, Math.max(0, p)));

  function angleTo(e: PointerEvent, reel: 0 | 1) {
    const box = rootRef.current!.getBoundingClientRect();
    const cx = box.left + (REELS[reel].x / W) * box.width;
    const cy = box.top + (REELS[reel].y / H) * box.height;
    return Math.atan2(e.clientY - cy, e.clientX - cx);
  }

  function onPointerDown(e: PointerEvent<HTMLDivElement>) {
    if (!onSeek || e.button !== 0) return;
    e.preventDefault();
    focusQuietly(e.currentTarget);
    e.currentTarget.setPointerCapture(e.pointerId);
    const box = e.currentTarget.getBoundingClientRect();
    const reel = e.clientX - box.left < box.width / 2 ? 0 : 1;
    drag.current = { id: e.pointerId, reel, angle: angleTo(e, reel), turned: 0, p: shown.current };
    play("press", { gain: 0.6 });
  }

  function onPointerMove(e: PointerEvent<HTMLDivElement>) {
    const d = drag.current;
    if (!d || d.id !== e.pointerId) return;
    const angle = angleTo(e, d.reel);
    let delta = angle - d.angle;
    if (delta > Math.PI) delta -= 2 * Math.PI;
    if (delta < -Math.PI) delta += 2 * Math.PI;
    d.angle = angle;
    // Clockwise winds forward on either reel; the tape moved is the turn times the radius under the hand.
    const { supply, takeUp } = transport(d.p);
    d.p = Math.min(1, Math.max(0, d.p + (delta * (d.reel ? takeUp : supply)) / LENGTH));
    d.turned += delta;
    if (Math.abs(d.turned) >= TICK_EVERY) {
      d.turned %= TICK_EVERY;
      play("tick", { gain: 0.6, pitch: 0.92 + d.p * 0.16 });
    }
    seek(d.p);
  }

  function onPointerEnd(e: PointerEvent<HTMLDivElement>) {
    if (drag.current?.id !== e.pointerId) return;
    drag.current = null;
    play("release", { gain: 0.6 });
    onSeekEnd?.();
  }

  function onKeyDown(e: KeyboardEvent<HTMLDivElement>) {
    if (!onSeek) return;
    const delta = { ArrowRight: 0.01, ArrowUp: 0.01, ArrowLeft: -0.01, ArrowDown: -0.01, PageUp: 0.1, PageDown: -0.1 }[e.key];
    const next = delta !== undefined ? progress + delta : e.key === "Home" ? 0 : e.key === "End" ? 1 : null;
    if (next === null) return;
    e.preventDefault();
    if ((next <= 0 && progress <= 0) || (next >= 1 && progress >= 1)) return play("bump", { gain: 0.6 });
    play("tick", { gain: 0.6, pitch: 0.92 + next * 0.16 });
    seek(next);
    onSeekEnd?.();
  }

  const percent = Math.round(progress * 100);
  const a11y = onSeek
    ? { role: "slider", tabIndex: 0, "aria-orientation": "horizontal" as const, onKeyDown }
    : { role: "progressbar" };

  return (
    <div
      ref={rootRef}
      {...a11y}
      aria-label={label}
      aria-valuemin={0}
      aria-valuemax={100}
      aria-valuenow={percent}
      aria-valuetext={`${percent}%`}
      onPointerDown={onPointerDown}
      onPointerMove={onPointerMove}
      onPointerUp={onPointerEnd}
      onPointerCancel={onPointerEnd}
      data-part="window"
      className={`relative isolate aspect-[200/110] w-full touch-none select-none overflow-hidden rounded-[0.8em] outline-offset-2 [background:var(--device-window)] shadow-(--device-window-edge) ${onSeek ? "cursor-grab active:cursor-grabbing" : ""} ${className}`}
    >
      {/* A fine grille behind the glass and the lamp that lights the transport, as in the maker's window. */}
      <div aria-hidden className="absolute inset-0 -z-10 [background-image:radial-gradient(rgb(255_255_255/0.06)_0.7px,transparent_0.9px)] [background-size:5px_5px]" />
      <div aria-hidden className="absolute inset-0 -z-10 [background:radial-gradient(60%_55%_at_50%_42%,rgb(255_255_255/0.1),transparent)]" />

      {REELS.map((reel, i) => (
        <div
          key={i}
          data-reel
          data-part="reel"
          aria-hidden
          className="absolute aspect-square rounded-full"
          style={
            {
              left: `${((reel.x - R) / W) * 100}%`,
              top: `${((reel.y - R) / H) * 100}%`,
              width: `${((2 * R) / W) * 100}%`,
              "--r": pct(HUB),
              "--a": "0deg",
            } as CSSProperties
          }
        >
          {/* The pack: oxide wound in fine layers, lit from above. Drawn, its edge in ink at --r and the layers as hairline rings: the mask still stops them where the tape does. */}
          <div
            className="absolute inset-0 rounded-full drawn:[background:radial-gradient(circle_closest-side,transparent_calc(var(--r)_-_2%),var(--device-draw-ink)_calc(var(--r)_-_1.6%),var(--device-draw-ink)_calc(var(--r)_-_0.4%),transparent_var(--r)),repeating-radial-gradient(circle_closest-side,transparent_0_9.4%,var(--device-draw-line)_10.2%,transparent_11%)]! drawn:[mask-image:radial-gradient(circle_closest-side,#000_calc(var(--r)_-_0.3%),transparent_calc(var(--r)_+_0.3%))]!"
            style={{
              background:
                "radial-gradient(circle closest-side, transparent calc(var(--r) - 0.6%), rgb(255 255 255 / 0.22) calc(var(--r) - 0.6%), rgb(255 255 255 / 0.22) var(--r), transparent var(--r)), repeating-radial-gradient(circle closest-side, rgb(255 255 255 / 0.035) 0 0.7%, transparent 0.7% 1.6%), radial-gradient(circle closest-side, #4b3426 0, #3a281d calc(var(--r) - 1%), #2c1e16 var(--r), transparent var(--r))",
              // The wound layers stop where the tape does.
              mask: "radial-gradient(circle closest-side, #000 var(--r), transparent var(--r))",
            }}
          />
          <div
            className="absolute inset-0 rounded-full"
            style={{
              background: "conic-gradient(from 290deg, transparent, rgb(255 255 255 / 0.1) 40deg, transparent 80deg, transparent 180deg, rgb(255 255 255 / 0.05) 220deg, transparent 260deg)",
              mask: "radial-gradient(circle closest-side, #000 var(--r), transparent var(--r))",
            }}
          />
          {/* The hub: three windows cut through it, and the spindle. Drawn, an outlined wheel over the rings, its windows hatched. */}
          <div
            className="absolute rounded-full [background:var(--device-wheel-face)] shadow-[0_0_0_0.5px_rgb(0_0_0/0.4),0_1px_3px_rgb(0_0_0/0.5)] drawn:bg-(--canvas)! drawn:outline drawn:outline-(--device-draw-ink)"
            style={{ inset: `${50 - (HUB / R) * 50}%` }}
          >
            <div
              className="absolute inset-0 rounded-full bg-(--device-rim) drawn:[background:repeating-linear-gradient(45deg,var(--device-draw-ink)_0_1px,transparent_1px_0.28em)]!"
              style={{
                mask: "conic-gradient(from var(--a), #000 0 46deg, transparent 46deg 120deg, #000 120deg 166deg, transparent 166deg 240deg, #000 240deg 286deg, transparent 286deg), radial-gradient(circle closest-side, transparent 42%, #000 43%, #000 84%, transparent 85%)",
                maskComposite: "intersect",
              }}
            />
            <div className="absolute inset-[38%] rounded-full bg-(--device-rim) shadow-[inset_0_0_0_1.5px_rgb(255_255_255/0.25)] drawn:bg-(--canvas)! drawn:outline drawn:outline-(--device-draw-ink)" />
          </div>
        </div>
      ))}

      {/* Guides, the head and the tape between them. */}
      <svg aria-hidden data-part="tape" viewBox={`0 0 ${W} ${H}`} className="absolute inset-0 size-full">
        <polyline ref={tapeRef} fill="none" stroke="#3a281d" strokeWidth="1.6" strokeLinejoin="round" className="drawn:stroke-(--device-draw-ink)! drawn:[stroke-width:0.9px]!" />
        {GUIDES.map((g, i) => (
          <g key={i}>
            <circle cx={g.x} cy={g.y} r={GUIDE_R} fill="#d9d9d7" stroke="rgb(0 0 0 / 0.5)" strokeWidth="0.6" className="drawn:fill-(--canvas)! drawn:stroke-(--device-draw-ink)!" />
            <circle cx={g.x} cy={g.y} r={GUIDE_R * 0.62} fill="none" stroke="rgb(0 0 0 / 0.18)" strokeWidth="0.5" className="drawn:stroke-(--device-draw-line)!" />
            <circle cx={g.x} cy={g.y} r={GUIDE_R * 0.26} fill="#7a7a78" className="drawn:fill-(--device-draw-ink)!" />
          </g>
        ))}
        <rect x={W / 2 - 9} y={TAPE_Y + 0.8} width="18" height="9" rx="1.6" fill="#cfcfcd" stroke="rgb(0 0 0 / 0.5)" strokeWidth="0.6" className="drawn:fill-(--canvas)! drawn:stroke-(--device-draw-ink)!" />
        <rect x={W / 2 - 5} y={TAPE_Y + 0.8} width="10" height="1.4" fill="#8e8e8c" className="drawn:fill-(--device-draw-line)!" />
        <circle
          cx={W / 2 + 14}
          cy={TAPE_Y + 5}
          r="1.6"
          className={recording ? "fill-(--device-rec) [filter:drop-shadow(0_0_2px_var(--device-rec))] drawn:[filter:none]!" : "fill-white/15 drawn:fill-transparent! drawn:stroke-(--device-draw-line)! drawn:[stroke-width:0.5px]!"}
        />
      </svg>

      {/* Glass: a sheen where the light catches it. */}
      <div aria-hidden data-part="glass" className="pointer-events-none absolute inset-0 [background:linear-gradient(112deg,rgb(255_255_255/0.07)_0%,rgb(255_255_255/0.02)_44%,transparent_44.2%)]" />
    </div>
  );
}

/* --- Demo: a take exporting to tape, on a loop -------------------------------- */

type Phase = "export" | "done" | "rewind" | "pause" | "stop";
const TAKE = 48; // seconds in the take being written
const chips: Record<Phase, string> = { export: "Export", done: "Done", rewind: "Rewind", pause: "Pause", stop: "Stop" };
const clock = (s: number) => `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
const jitter = () => 0.022 + Math.random() * 0.026;

export default function Demo() {
  const [progress, setProgress] = useState(0);
  const [phase, setPhase] = useState<Phase>("stop");
  const [announcement, setAnnouncement] = useState("");
  const rootRef = useRef<HTMLDivElement>(null);
  const at = useRef(0); // progress, ahead of the render that shows it
  const touched = useRef(false);
  const ids = useId();

  const moveTo = (p: number) => {
    at.current = p;
    setProgress(p);
  };

  // Sounds follow the host's tape (see hostTransport): aloud while it plays, or once a hand is on this one.
  const sound = (name: "start" | "stop") => {
    if (touched.current || hostTransport(rootRef.current) === "play") play(name);
  };

  // Writing: progress arrives in uneven steps, as an export reports it, until the take is on tape.
  const write = useEffectEvent(() => {
    const next = Math.min(1, at.current + jitter());
    moveTo(next);
    if (next < 1) return;
    setPhase("done");
    sound("stop");
    setAnnouncement("Export done");
  });
  useEffect(() => {
    if (phase !== "export") return;
    const id = setInterval(write, 110);
    return () => clearInterval(id);
  }, [phase]);

  // Done: hold, rewind at speed, then write the take again.
  const restart = useEffectEvent(() => {
    setPhase("export");
    sound("start");
  });
  const rewind = useEffectEvent(() => {
    setPhase("rewind");
    moveTo(0);
  });
  useEffect(() => {
    if (phase !== "done" && phase !== "rewind") return;
    const id = setTimeout(phase === "done" ? rewind : restart, phase === "done" ? 1100 : 900);
    return () => clearTimeout(id);
  }, [phase]);

  // Power up: start writing unless the host's tape is stopped. Under reduced motion, sit part way in.
  const powerUp = useEffectEvent(() => {
    if (matchMedia("(prefers-reduced-motion: reduce)").matches) {
      moveTo(0.64);
      setPhase("pause");
    } else if (hostTransport(rootRef.current) !== "stop") restart();
  });
  useEffect(() => {
    const id = setTimeout(powerUp, 300);
    return () => clearTimeout(id);
  }, []);

  function toggle() {
    if (phase === "export") {
      setPhase("pause");
      play("stop", { gain: 0.55, pitch: 1.25 });
      setAnnouncement("Paused");
    } else {
      if (at.current >= 1) moveTo(0);
      setPhase("export");
      play("start");
      setAnnouncement("Exporting");
    }
  }

  const running = phase === "export" || phase === "rewind";

  return (
    <div
      ref={rootRef}
      onPointerDownCapture={() => (touched.current = true)}
      onKeyDownCapture={() => (touched.current = true)}
      className="@container w-full max-w-[440px] select-none"
    >
      <div data-part="plate" className="relative isolate animate-enter overflow-hidden rounded-[1.25em] p-[0.9em] text-[clamp(11px,4cqw,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)] @[26rem]:p-[1.1em]">
        <div aria-hidden className="device-grain pointer-events-none absolute inset-0 -z-10 rounded-[inherit]" />

        {/* The transport sits in a well pressed into the plate. */}
        <div data-part="well" className="rounded-[1.05em] bg-(--device-well) p-[0.4em] shadow-(--device-recess)">
          <TapeReels
            label="Export progress, take_04.wav"
            progress={progress}
            recording={phase === "export"}
            onSeek={(p) => {
              if (phase !== "pause") setPhase("pause");
              moveTo(p);
            }}
          />
        </div>

        <div className="mt-[0.7em] flex items-stretch gap-[0.7em]">
          <div
            aria-hidden
            data-part="lcd"
            className="flex h-[3.4em] min-w-0 flex-1 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 min-w-0 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">
                <span className={phase === "export" ? "size-[0.55em] animate-pulse rounded-full bg-(--device-rec) drawn:bg-(--device-rec)!" : "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="truncate text-[0.7em] text-(--device-lcd-dim) drawn:opacity-60">take_04.wav</span>
            </div>
            <p className="flex items-baseline justify-between gap-[0.6em] leading-none tabular-nums">
              <span className="text-[1.15em] font-light tracking-[-0.02em]">
                {clock(progress * TAKE)}
                <span className="text-(--device-lcd-dim) drawn:opacity-60"> / {clock(TAKE)}</span>
              </span>
              <span className="text-[0.7em] text-(--device-lcd-dim) drawn:opacity-60">{Math.round(progress * 100)} %</span>
            </p>
          </div>

          <button
            type="button"
            aria-label={phase === "export" ? "Pause" : "Export"}
            aria-pressed={running}
            aria-describedby={`${ids}-hint`}
            onPointerDown={(e) => e.button === 0 && focusQuietly(e.currentTarget)}
            onClick={toggle}
            data-part="collar"
            className="group/key grid size-[3.4em] shrink-0 place-items-center rounded-full bg-black/[0.035] p-[0.26em] shadow-(--device-recess) outline-offset-2 dark:bg-black/30"
          >
            <span data-part="cap" className="grid size-full place-items-center rounded-full [background:var(--device-wheel-face)] shadow-(--device-key-shadow) transition-[transform,box-shadow] duration-(--duration-exit) ease-out group-active/key:translate-y-[2px] group-active/key:shadow-(--device-key-shadow-pressed) group-active/key:duration-75">
              <svg aria-hidden data-part="lettering" viewBox="0 0 16 16" className="size-[1.3em] fill-(--device-rec)">
                {phase === "export" ? <path d="M4 2.5h2.8v11H4zM9.2 2.5H12v11H9.2z" /> : <path d="M4.5 2.4 13.2 8l-8.7 5.6z" />}
              </svg>
            </span>
          </button>
        </div>
      </div>

      <p id={`${ids}-hint`} className="sr-only">
        Turn a reel to scrub, or focus the window and use the arrow keys.
      </p>
      <p role="status" aria-live="polite" className="sr-only">
        {announcement}
      </p>
    </div>
  );
}