Yes. Your plan runs to the end of the period you paid for, then stops.
Zoetrope
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>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initCardZoetrope(el) on the mounted element instead.
<div class="card-zoetrope" role="group" aria-label="Zoetrope disc: twelve drawings of a running figure. Spin it to see them move.">
<svg class="card-zoetrope__frame" viewBox="0 0 228 228" aria-hidden="true">
<circle class="card-zoetrope__rim" cx="114" cy="114" r="113"></circle>
<defs>
<path id="card-zoetrope-arc" d="M114 114 m-100 0 a100 100 0 1 1 200 0 a100 100 0 1 1 -200 0"></path>
</defs>
<text class="card-zoetrope__rim-text">
<textPath href="#card-zoetrope-arc" startOffset="1%">The runner · plate one · twelve frames · spin the disc · The runner ·</textPath>
</text>
</svg>
<svg class="card-zoetrope__disc" viewBox="-93 -93 186 186" aria-hidden="true">
<circle class="card-zoetrope__paper" r="93"></circle>
<circle class="card-zoetrope__ring" r="42"></circle>
<circle class="card-zoetrope__ring" r="63"></circle>
<g transform="rotate(0)">
<rect class="card-zoetrope__slit" x="-2" y="-92" width="4" height="9" rx="1"></rect>
<g transform="translate(0 -65) scale(0.72)">
<path class="card-zoetrope__figure" d="M0 -17.0L3.4 -28.0M0.0 -17.0L1.1 -8.1L-8.4 -7.7M0.0 -17.0L1.1 -8.1L-5.7 -1.4M3.4 -28.0L3.4 -21.4L9.4 -21.0M3.4 -28.0L3.4 -21.4L9.4 -21.0"></path>
<circle class="card-zoetrope__head" cx="5.2" cy="-33" r="4.4"></circle>
</g>
<g transform="rotate(15)">
<ellipse class="card-zoetrope__head" cx="0" cy="-46.6" rx="5.13" ry="2.66"></ellipse>
</g>
</g>
<g transform="rotate(30)">
<rect class="card-zoetrope__slit" x="-2" y="-92" width="4" height="9" rx="1"></rect>
<g transform="translate(0 -65) scale(0.72)">
<path class="card-zoetrope__figure" d="M0 -18.2L3.4 -29.2M0.0 -18.2L-2.7 -9.6L-11.2 -13.9M0.0 -18.2L4.7 -10.5L2.0 -1.4M3.4 -29.2L6.3 -23.3L11.8 -25.5M3.4 -29.2L0.5 -23.3L5.7 -20.3"></path>
<circle class="card-zoetrope__head" cx="5.2" cy="-34.2" r="4.4"></circle>
</g>
<g transform="rotate(15)">
<ellipse class="card-zoetrope__head" cx="0" cy="-48.61" rx="4.061" ry="3.576"></ellipse>
</g>
</g>
<g transform="rotate(60)">
<rect class="card-zoetrope__slit" x="-2" y="-92" width="4" height="9" rx="1"></rect>
<g transform="translate(0 -65) scale(0.72)">
<path class="card-zoetrope__figure" d="M0 -19.1L3.4 -30.1M0.0 -19.1L-5.2 -11.7L-12.3 -18.1M0.0 -19.1L6.8 -13.2L6.6 -3.7M3.4 -30.1L8.0 -25.4L12.6 -29.3M3.4 -30.1L-1.2 -25.4L2.7 -20.9"></path>
<circle class="card-zoetrope__head" cx="5.2" cy="-35.078" r="4.4"></circle>
</g>
<g transform="rotate(15)">
<ellipse class="card-zoetrope__head" cx="0" cy="-54.1" rx="3.8" ry="3.8"></ellipse>
</g>
</g>
<g transform="rotate(90)">
<rect class="card-zoetrope__slit" x="-2" y="-92" width="4" height="9" rx="1"></rect>
<g transform="translate(0 -65) scale(0.72)">
<path class="card-zoetrope__figure" d="M0 -19.4L3.4 -30.4M0.0 -19.4L-6.0 -12.7L-13.3 -18.8M0.0 -19.4L7.4 -14.3L6.9 -4.8M3.4 -30.4L8.6 -26.3L12.6 -30.7M3.4 -30.4L-1.8 -26.3L1.6 -21.3"></path>
<circle class="card-zoetrope__head" cx="5.2" cy="-35.4" r="4.4"></circle>
</g>
<g transform="rotate(15)">
<ellipse class="card-zoetrope__head" cx="0" cy="-61.6" rx="3.8" ry="3.8"></ellipse>
</g>
</g>
<g transform="rotate(120)">
<rect class="card-zoetrope__slit" x="-2" y="-92" width="4" height="9" rx="1"></rect>
<g transform="translate(0 -65) scale(0.72)">
<path class="card-zoetrope__figure" d="M0 -19.1L3.4 -30.1M0.0 -19.1L-5.2 -11.7L-14.1 -15.1M0.0 -19.1L6.8 -13.2L3.2 -4.4M3.4 -30.1L8.0 -25.4L12.6 -29.3M3.4 -30.1L-1.2 -25.4L2.7 -20.9"></path>
<circle class="card-zoetrope__head" cx="5.2" cy="-35.078" r="4.4"></circle>
</g>
<g transform="rotate(15)">
<ellipse class="card-zoetrope__head" cx="0" cy="-54.1" rx="3.8" ry="3.8"></ellipse>
</g>
</g>
<g transform="rotate(150)">
<rect class="card-zoetrope__slit" x="-2" y="-92" width="4" height="9" rx="1"></rect>
<g transform="translate(0 -65) scale(0.72)">
<path class="card-zoetrope__figure" d="M0 -18.2L3.4 -29.2M0.0 -18.2L-2.7 -9.6L-12.0 -7.9M0.0 -18.2L4.7 -10.5L-2.9 -4.8M3.4 -29.2L6.3 -23.3L11.8 -25.5M3.4 -29.2L0.5 -23.3L5.7 -20.3"></path>
<circle class="card-zoetrope__head" cx="5.2" cy="-34.2" r="4.4"></circle>
</g>
<g transform="rotate(15)">
<ellipse class="card-zoetrope__head" cx="0" cy="-48.61" rx="4.061" ry="3.576"></ellipse>
</g>
</g>
<g transform="rotate(180)">
<rect class="card-zoetrope__slit" x="-2" y="-92" width="4" height="9" rx="1"></rect>
<g transform="translate(0 -65) scale(0.72)">
<path class="card-zoetrope__figure" d="M0 -17.0L3.4 -28.0M0.0 -17.0L1.1 -8.1L-5.7 -1.4M0.0 -17.0L1.1 -8.1L-8.4 -7.7M3.4 -28.0L3.4 -21.4L9.4 -21.0M3.4 -28.0L3.4 -21.4L9.4 -21.0"></path>
<circle class="card-zoetrope__head" cx="5.2" cy="-33" r="4.4"></circle>
</g>
<g transform="rotate(15)">
<ellipse class="card-zoetrope__head" cx="0" cy="-46.6" rx="5.13" ry="2.66"></ellipse>
</g>
</g>
<g transform="rotate(210)">
<rect class="card-zoetrope__slit" x="-2" y="-92" width="4" height="9" rx="1"></rect>
<g transform="translate(0 -65) scale(0.72)">
<path class="card-zoetrope__figure" d="M0 -18.2L3.4 -29.2M0.0 -18.2L4.7 -10.5L2.0 -1.4M0.0 -18.2L-2.7 -9.6L-11.2 -13.9M3.4 -29.2L0.5 -23.3L5.7 -20.3M3.4 -29.2L6.3 -23.3L11.8 -25.5"></path>
<circle class="card-zoetrope__head" cx="5.2" cy="-34.2" r="4.4"></circle>
</g>
<g transform="rotate(15)">
<ellipse class="card-zoetrope__head" cx="0" cy="-48.61" rx="4.061" ry="3.576"></ellipse>
</g>
</g>
<g transform="rotate(240)">
<rect class="card-zoetrope__slit" x="-2" y="-92" width="4" height="9" rx="1"></rect>
<g transform="translate(0 -65) scale(0.72)">
<path class="card-zoetrope__figure" d="M0 -19.1L3.4 -30.1M0.0 -19.1L6.8 -13.2L6.6 -3.7M0.0 -19.1L-5.2 -11.7L-12.3 -18.1M3.4 -30.1L-1.2 -25.4L2.7 -20.9M3.4 -30.1L8.0 -25.4L12.6 -29.3"></path>
<circle class="card-zoetrope__head" cx="5.2" cy="-35.078" r="4.4"></circle>
</g>
<g transform="rotate(15)">
<ellipse class="card-zoetrope__head" cx="0" cy="-54.1" rx="3.8" ry="3.8"></ellipse>
</g>
</g>
<g transform="rotate(270)">
<rect class="card-zoetrope__slit" x="-2" y="-92" width="4" height="9" rx="1"></rect>
<g transform="translate(0 -65) scale(0.72)">
<path class="card-zoetrope__figure" d="M0 -19.4L3.4 -30.4M0.0 -19.4L7.4 -14.3L6.9 -4.8M0.0 -19.4L-6.0 -12.7L-13.3 -18.8M3.4 -30.4L-1.8 -26.3L1.6 -21.3M3.4 -30.4L8.6 -26.3L12.6 -30.7"></path>
<circle class="card-zoetrope__head" cx="5.2" cy="-35.4" r="4.4"></circle>
</g>
<g transform="rotate(15)">
<ellipse class="card-zoetrope__head" cx="0" cy="-61.6" rx="3.8" ry="3.8"></ellipse>
</g>
</g>
<g transform="rotate(300)">
<rect class="card-zoetrope__slit" x="-2" y="-92" width="4" height="9" rx="1"></rect>
<g transform="translate(0 -65) scale(0.72)">
<path class="card-zoetrope__figure" d="M0 -19.1L3.4 -30.1M0.0 -19.1L6.8 -13.2L3.2 -4.4M0.0 -19.1L-5.2 -11.7L-14.1 -15.1M3.4 -30.1L-1.2 -25.4L2.7 -20.9M3.4 -30.1L8.0 -25.4L12.6 -29.3"></path>
<circle class="card-zoetrope__head" cx="5.2" cy="-35.078" r="4.4"></circle>
</g>
<g transform="rotate(15)">
<ellipse class="card-zoetrope__head" cx="0" cy="-54.1" rx="3.8" ry="3.8"></ellipse>
</g>
</g>
<g transform="rotate(330)">
<rect class="card-zoetrope__slit" x="-2" y="-92" width="4" height="9" rx="1"></rect>
<g transform="translate(0 -65) scale(0.72)">
<path class="card-zoetrope__figure" d="M0 -18.2L3.4 -29.2M0.0 -18.2L4.7 -10.5L-2.9 -4.8M0.0 -18.2L-2.7 -9.6L-12.0 -7.9M3.4 -29.2L0.5 -23.3L5.7 -20.3M3.4 -29.2L6.3 -23.3L11.8 -25.5"></path>
<circle class="card-zoetrope__head" cx="5.2" cy="-34.2" r="4.4"></circle>
</g>
<g transform="rotate(15)">
<ellipse class="card-zoetrope__head" cx="0" cy="-48.61" rx="4.061" ry="3.576"></ellipse>
</g>
</g>
</svg>
<button type="button" class="card-zoetrope__spin" aria-label="Spin the disc">Spin</button>
</div>.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; }function initCardZoetrope(root) {
var STEP = 30;
var face = root.querySelector('.card-zoetrope__disc');
var spin = root.querySelector('.card-zoetrope__spin');
var calm = matchMedia('(prefers-reduced-motion: reduce)').matches;
var angle = 0;
var vel = calm ? 0 : 300;
var hovering = false;
var held = false;
var visible = false;
var raf = 0;
var last = 0;
var grab = 0;
var lastAngle = 0;
var lastTime = 0;
function pointerAngle(e) {
var b = root.getBoundingClientRect();
return (Math.atan2(e.clientY - b.top - b.height / 2, e.clientX - b.left - b.width / 2) * 180) / Math.PI;
}
function 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.
var strobing = !held && Math.abs(vel) > 90 && !calm;
face.style.transform = 'rotate(' + (strobing ? Math.round(angle / STEP) * STEP : angle).toFixed(2) + 'deg)';
}
function tick(t) {
var dt = Math.min(0.05, (t - last) / 1000 || 0.016);
last = t;
if (!held) {
if (hovering && !calm) vel += ((vel < 0 ? -1 : 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();
raf = visible && (held || vel !== 0 || hovering) ? requestAnimationFrame(tick) : 0;
}
function wake() {
if (!raf && visible) {
last = performance.now();
raf = requestAnimationFrame(tick);
}
}
var io = new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
if (visible) wake();
});
function onSpin() {
if (calm) {
angle += STEP;
paint();
return;
}
vel = 340;
wake();
}
function onDown(e) {
if (e.target.closest('button')) return;
held = true;
grab = pointerAngle(e) - angle;
lastAngle = angle;
lastTime = e.timeStamp;
vel = 0;
root.setPointerCapture(e.pointerId);
wake();
}
function onMove(e) {
if (!held) return;
angle += ((pointerAngle(e) - grab - angle + 540) % 360) - 180;
var dt = (e.timeStamp - lastTime) / 1000;
if (dt > 0.008) {
vel = (angle - lastAngle) / dt;
lastAngle = angle;
lastTime = e.timeStamp;
}
}
function onUp() {
held = false;
wake();
}
function onEnter() {
hovering = true;
wake();
}
function onLeave() {
hovering = false;
}
io.observe(root);
spin.addEventListener('click', onSpin);
root.addEventListener('pointerdown', onDown);
root.addEventListener('pointermove', onMove);
root.addEventListener('pointerup', onUp);
root.addEventListener('pointercancel', onUp);
root.addEventListener('pointerenter', onEnter);
root.addEventListener('pointerleave', onLeave);
return function () {
io.disconnect();
cancelAnimationFrame(raf);
spin.removeEventListener('click', onSpin);
root.removeEventListener('pointerdown', onDown);
root.removeEventListener('pointermove', onMove);
root.removeEventListener('pointerup', onUp);
root.removeEventListener('pointercancel', onUp);
root.removeEventListener('pointerenter', onEnter);
root.removeEventListener('pointerleave', onLeave);
};
}
document.querySelectorAll('.card-zoetrope').forEach(initCardZoetrope);Goal: Create a "Zoetrope" card.
Visual behavior: 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.
Technique:
- The twelve runners are worked out joint by joint, one for each moment of a stride, and placed around the disc in reverse so that clockwise turns play them forward.
- The trick: above a certain speed the disc only ever shows whole 30 degree steps, so each drawing lands exactly where the last one was and the eye sees one figure moving. Below that speed it turns smoothly, and drag sets the speed with friction bringing it to rest.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Zoetrope" card.
Visual behavior: 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.
Technique:
- The twelve runners are worked out joint by joint, one for each moment of a stride, and placed around the disc in reverse so that clockwise turns play them forward.
- The trick: above a certain speed the disc only ever shows whole 30 degree steps, so each drawing lands exactly where the last one was and the eye sees one figure moving. Below that speed it turns smoothly, and drag sets the speed with friction bringing it to rest.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
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.