Zoetrope

No dependencies
Code format
React + Tailwind
import { useEffect, useId, useRef } from 'react';

const FRAMES = 12;
const STEP = 360 / FRAMES;

// A figure on the run, worked out joint by joint for one moment of the stride.
function runner(p: number) {
  const t = p * Math.PI * 2;
  const bob = Math.abs(Math.sin(t)) * 2.4;
  const hip = { x: 0, y: -17 - bob };
  const neck = { x: 3.4, y: hip.y - 11 };
  const limb = (from: { x: number; y: number }, a1: number, l1: number, a2: number, l2: number) => {
    const j = { x: from.x + Math.sin(a1) * l1, y: from.y + Math.cos(a1) * l1 };
    const e = { x: j.x + Math.sin(a2) * l2, y: j.y + Math.cos(a2) * l2 };
    return `M${from.x.toFixed(1)} ${from.y.toFixed(1)}L${j.x.toFixed(1)} ${j.y.toFixed(1)}L${e.x.toFixed(1)} ${e.y.toFixed(1)}`;
  };
  const leg = (phase: number) => {
    const swing = Math.sin(t + phase);
    const thigh = swing * 0.85 + 0.12;
    const knee = thigh - (0.55 + 0.9 * (1 + Math.cos(t + phase + 0.6)) * 0.5 + 0.28);
    return limb(hip, thigh, 9, knee, 9.5);
  };
  const arm = (phase: number) => {
    const swing = Math.sin(t + phase);
    return limb(neck, swing * 0.9, 6.6, swing * 0.9 + 1.5, 6);
  };
  return {
    d: `M${hip.x} ${hip.y.toFixed(1)}L${neck.x} ${neck.y.toFixed(1)}${leg(0)}${leg(Math.PI)}${arm(Math.PI)}${arm(0)}`,
    hx: neck.x + 1.8,
    hy: neck.y - 5,
  };
}
// Rotating clockwise walks each slot back one drawing, so the order runs in reverse.
const RUN = Array.from({ length: FRAMES }, (_, i) => runner((FRAMES - i) / FRAMES));
const BALL = Array.from({ length: FRAMES }, (_, i) => {
  const p = (FRAMES - i) / FRAMES;
  const h = Math.abs(Math.cos(p * Math.PI * 2));
  const low = 1 - h;
  return { y: -3.6 - low * 15, sx: 1 + Math.max(0, 0.5 - low * 3) * 0.7, sy: 1 - Math.max(0, 0.5 - low * 3) * 0.6 };
});

const css = `
  .card-zoetrope { flex: none; --disc: light-dark(#fbfcfd, #f1f2f3); --print: light-dark(#373a40, #121417); --ring: light-dark(#42454c, #232529); --ring-ink: light-dark(#fbfcfd, #f1f2f3); position: relative; width: 228px; height: 228px; touch-action: none; -webkit-user-select: none; user-select: none; }
  .card-zoetrope__frame { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; filter: drop-shadow(0 6px 10px light-dark(rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.34))); }
  .card-zoetrope__rim { fill: var(--ring); }
  .card-zoetrope__rim-text { fill: var(--ring-ink); font: 700 9.5px system-ui, sans-serif; letter-spacing: 0.19em; text-transform: uppercase; }
  .card-zoetrope__disc { position: absolute; left: 21px; top: 21px; width: 186px; height: 186px; overflow: visible; border-radius: 50%; cursor: grab; will-change: transform; }
  .card-zoetrope__disc:active { cursor: grabbing; }
  .card-zoetrope__paper { fill: var(--disc); }
  .card-zoetrope__slit { fill: var(--print); }
  .card-zoetrope__figure { fill: none; stroke: var(--print); stroke-width: 2.5; stroke-linecap: round; stroke-linejoin: round; }
  .card-zoetrope__head { fill: var(--print); }
  .card-zoetrope__ring { fill: none; stroke: var(--print); stroke-width: 0.8; }
  .card-zoetrope__spin { position: absolute; left: 50%; top: 50%; display: grid; place-items: center; width: 42px; height: 42px; margin: -21px; padding: 0; border: 0; border-radius: 50%; background: var(--ring); color: var(--ring-ink); font: 700 10px/1 system-ui, sans-serif; letter-spacing: 0.03em; cursor: pointer; box-shadow: 0 2px 4px light-dark(rgba(0, 0, 0, 0.14), rgba(0, 0, 0, 0.45)), inset 0 1px 0 color-mix(in oklab, var(--ring-ink) 26%, transparent); }
  .card-zoetrope__spin:focus-visible { outline: 2px solid light-dark(#373a40, #f1f2f3); outline-offset: 2px; }
  .card-zoetrope__spin:active { scale: 0.94; }
`;

export function ZoetropeCard() {
  const uid = useId().replace(/[^a-zA-Z0-9]/g, '');
  const root = useRef<HTMLDivElement>(null);
  const disc = useRef<SVGSVGElement>(null);
  const kick = useRef<() => void>(() => {});

  useEffect(() => {
    const el = root.current;
    const face = disc.current;
    if (!el || !face) return;
    const calm = matchMedia('(prefers-reduced-motion: reduce)').matches;
    let angle = 0;
    let vel = calm ? 0 : 300;
    let hovering = false;
    let held = false;
    let visible = false;
    let raf = 0;
    let last = 0;
    let grabAngle = 0;
    let lastAngle = 0;
    let lastTime = 0;

    const pointerAngle = (e: PointerEvent) => {
      const b = el.getBoundingClientRect();
      return (Math.atan2(e.clientY - b.top - b.height / 2, e.clientX - b.left - b.width / 2) * 180) / Math.PI;
    };
    const paint = () => {
      // The whole trick: fast enough, the disc only ever shows whole steps, so
      // each drawing lands exactly where the last one was and the eye sees it move.
      const strobing = !held && Math.abs(vel) > 90 && !calm;
      const shown = strobing ? Math.round(angle / STEP) * STEP : angle;
      face.style.transform = `rotate(${shown.toFixed(2)}deg)`;
    };
    const tick = (t: number) => {
      const dt = Math.min(0.05, (t - last) / 1000 || 0.016);
      last = t;
      if (!held) {
        if (hovering && !calm) vel += (Math.sign(vel || 1) * 300 - vel) * Math.min(1, dt * 3);
        else vel *= Math.exp(-dt * 0.42);
        vel = Math.max(-420, Math.min(420, vel));
        angle += vel * dt;
        if (Math.abs(vel) < 10) vel = 0;
      }
      paint();
      if (visible && (held || vel !== 0 || hovering)) raf = requestAnimationFrame(tick);
      else raf = 0;
    };
    const wake = () => {
      if (!raf && visible) {
        last = performance.now();
        raf = requestAnimationFrame(tick);
      }
    };
    const io = new IntersectionObserver(([entry]) => {
      visible = entry.isIntersecting;
      if (visible) wake();
    });
    io.observe(el);
    kick.current = () => {
      if (calm) {
        angle += STEP;
        paint();
        return;
      }
      vel = 340;
      wake();
    };
    const down = (e: PointerEvent) => {
      if ((e.target as Element).closest('button')) return;
      held = true;
      grabAngle = pointerAngle(e) - angle;
      lastAngle = angle;
      lastTime = e.timeStamp;
      vel = 0;
      el.setPointerCapture(e.pointerId);
      wake();
    };
    const move = (e: PointerEvent) => {
      if (!held) return;
      const next = pointerAngle(e) - grabAngle;
      let delta = next - angle;
      delta = ((delta + 540) % 360) - 180;
      angle += delta;
      const dt = (e.timeStamp - lastTime) / 1000;
      if (dt > 0.008) {
        vel = (angle - lastAngle) / dt;
        lastAngle = angle;
        lastTime = e.timeStamp;
      }
    };
    const up = () => {
      held = false;
      wake();
    };
    const over = () => {
      hovering = true;
      wake();
    };
    const out = () => {
      hovering = false;
    };
    el.addEventListener('pointerdown', down);
    el.addEventListener('pointermove', move);
    el.addEventListener('pointerup', up);
    el.addEventListener('pointercancel', up);
    el.addEventListener('pointerenter', over);
    el.addEventListener('pointerleave', out);
    return () => {
      io.disconnect();
      cancelAnimationFrame(raf);
      el.removeEventListener('pointerdown', down);
      el.removeEventListener('pointermove', move);
      el.removeEventListener('pointerup', up);
      el.removeEventListener('pointercancel', up);
      el.removeEventListener('pointerenter', over);
      el.removeEventListener('pointerleave', out);
    };
  }, []);

  return (
    <>
      <style>
        {css}
      </style>
      <div ref={root} className="card-zoetrope" role="group" aria-label="Zoetrope disc: twelve drawings of a running figure. Spin it to see them move.">
        <svg className="card-zoetrope__frame" viewBox="0 0 228 228" aria-hidden="true">
          <circle className="card-zoetrope__rim" cx="114" cy="114" r="113" />
          <defs>
            <path id={`${uid}arc`} d="M114 114 m-100 0 a100 100 0 1 1 200 0 a100 100 0 1 1 -200 0" />
          </defs>
          <text className="card-zoetrope__rim-text">
            <textPath href={`#${uid}arc`} startOffset="1%">
              The runner · plate one · twelve frames · spin the disc · The runner ·
            </textPath>
          </text>
        </svg>
        <svg ref={disc} className="card-zoetrope__disc" viewBox="-93 -93 186 186" aria-hidden="true">
          <circle className="card-zoetrope__paper" r="93" />
          <circle className="card-zoetrope__ring" r="42" />
          <circle className="card-zoetrope__ring" r="63" />
          {Array.from({ length: FRAMES }, (_, i) => (
            <g key={i} transform={`rotate(${i * STEP})`}>
              <rect className="card-zoetrope__slit" x="-2" y="-92" width="4" height="9" rx="1" />
              <g transform="translate(0 -65) scale(0.72)">
                <path className="card-zoetrope__figure" d={RUN[i].d} />
                <circle className="card-zoetrope__head" cx={RUN[i].hx} cy={RUN[i].hy} r="4.4" />
              </g>
              <g transform={`rotate(${STEP / 2})`}>
                <ellipse className="card-zoetrope__head" cx="0" cy={-43 + BALL[i].y} rx={3.8 * BALL[i].sx} ry={3.8 * BALL[i].sy} />
              </g>
            </g>
          ))}
        </svg>
        <button type="button" className="card-zoetrope__spin" aria-label="Spin the disc" onClick={() => kick.current()}>
          Spin
        </button>
      </div>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/card-zoetrope
Source on GitHub

About this component

A Victorian spinning disc. Twelve drawings around the rim stand still as a disc, but spin it and the optical trick kicks in: the figures run and the balls bounce, with no video anywhere.

Dependencies
None
Difficulty
Advanced
Triggers
card, mouse tracking, click, continuous
Techniques
raf, event position, svg, transform only
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

A named group with a real Spin button, and the disc itself is aria-hidden. Under prefers-reduced-motion nothing runs by itself: Spin advances the disc one frame and dragging turns it by hand.

More cards

See all

Yes. Your plan runs to the end of the period you paid for, then stops.

Accordion

Inbox

  • Ana Ruiz

    The prints are in, come by after 4

  • Jun Bae

    Proofs attached, two small fixes

Actions
Carousel

Stoneware mug

$24
Cart