Marketing Sections & Logo Cloudsv0.4.0New
Coin Crew Hero
A hero card with a parade of coin characters you can poke, flip and fling.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/coin-crew-hero.jsonManual
Install the dependencies
npm install clsx motion tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/random.ts/** * Seeded randomness for visuals: dot layouts, characters' moods, print glitches. The same seed * gives the same sequence on the server and in the first client frame, so nothing shifts or * mismatches on hydration. Not for anything security-related. */ /** A 32-bit hash of a string or number, to turn a `seed` prop into a generator state. */ export function hashSeed(seed: string | number): number { const text = typeof seed === "number" ? `n${seed}` : seed; // FNV-1a, then a final avalanche so short, similar seeds still diverge. let h = 0x811c9dc5; for (let i = 0; i < text.length; i++) { h ^= text.charCodeAt(i); h = Math.imul(h, 0x01000193); } h ^= h >>> 16; h = Math.imul(h, 0x7feb352d); h ^= h >>> 15; return h >>> 0; } /** mulberry32: a small, fast generator of floats in [0, 1). */ export function mulberry32(state: number): () => number { let a = state >>> 0; return () => { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } export interface Random { /** A float in [0, 1). */ next(): number; /** A float in [min, max). */ range(min: number, max: number): number; /** An integer from min to max, both included. */ int(min: number, max: number): number; /** True with probability `p`. */ chance(p: number): boolean; /** One item of a non-empty list. */ pick<T>(list: readonly T[]): T; /** An independent generator derived from this seed and a salt, e.g. one per word or per coin. */ fork(salt: string | number): Random; } /** A seeded generator with a few helpers on top of `mulberry32`. */ export function createRandom(seed: string | number): Random { const base = hashSeed(seed); const next = mulberry32(base); return { next, range: (min, max) => min + next() * (max - min), int: (min, max) => min + Math.floor(next() * (max - min + 1)), chance: (p) => next() < p, pick: (list) => list[Math.floor(next() * list.length)], fork: (salt) => createRandom(`${base}:${salt}`), }; }Copy the source
components/ui/coin-crew-hero/coin-crew-hero.tsx"use client"; import { useEffect, useRef, useState, type ComponentProps, type ReactNode } from "react"; import { useReducedMotion } from "motion/react"; import { cn } from "@/lib/utils"; import { createRandom } from "@/lib/random"; export type CoinMood = "happy" | "grin" | "wink" | "sleepy" | "squint" | "surprised" | "meh" | "sad" | "cool" | "shy"; export interface CoinAgent { /** Flat face color; the rim is a darker shade of it. */ color: string; /** The face it starts with. */ mood: CoinMood; } export interface CoinCrewHeroProps extends Omit<ComponentProps<"section">, "title" | "children"> { /** Headline, top left. @default "Meet your new team" */ title?: ReactNode; /** Two-line description under the headline. */ description?: ReactNode; /** The black pill call to action. Pass `href` for a link or `onClick` for a button. @default { label: "Hire the crew", href: "#" } */ cta?: { label: string; href?: string; onClick?: () => void }; /** The coins in the parade, in order. @default a seeded crew drawn from `palette` */ agents?: CoinAgent[]; /** Colors for the default crew. @default eight bright flats */ palette?: string[]; /** Conveyor speed multiplier. @default 1 */ speed?: number; /** Seed for the default crew, blinks and new moods. @default "crew" */ seed?: string | number; /** Called when a coin is poked (clicked, tapped or via the "Poke a coin" button). */ onPoke?: (agent: CoinAgent, index: number) => void; } const MOODS: CoinMood[] = ["happy", "grin", "wink", "sleepy", "squint", "surprised", "meh", "sad", "cool", "shy"]; const PALETTE = ["#ff6b4a", "#ffc53d", "#3ec486", "#3e8bff", "#a77bff", "#ff7eb6", "#20c4d6", "#ff9f1c"]; /** Coins on the belt at once. */ const SLOTS = 8; /** Stacked layers that make the rim. */ const RIM = 8; const INK = "#1c1917"; /** The face, drawn in a -50…50 box. The eyes group is moved and blinked by the loop. */ function Face({ mood }: { mood: CoinMood }) { const eyes = (() => { switch (mood) { case "grin": return <path d="M-19 2q5-8 10 0M9 2q5-8 10 0" stroke={INK} strokeWidth="4" fill="none" strokeLinecap="round" />; case "wink": return ( <> <circle cx="-13" cy="0" r="5" fill={INK} /> <path d="M8 2q5-7 10 0" stroke={INK} strokeWidth="4" fill="none" strokeLinecap="round" /> </> ); case "sleepy": return <path d="M-19 0q6 5 12 0M7 0q6 5 12 0" stroke={INK} strokeWidth="4" fill="none" strokeLinecap="round" />; case "squint": return <path d="M-19-5l9 5-9 5M19-5l-9 5 9 5" stroke={INK} strokeWidth="4" fill="none" strokeLinecap="round" strokeLinejoin="round" />; case "surprised": return ( <> <ellipse cx="-13" cy="0" rx="5" ry="7.5" fill={INK} /> <ellipse cx="13" cy="0" rx="5" ry="7.5" fill={INK} /> </> ); case "cool": return <path d="M-24-5h48v4q0 9-10 9h-6q-6 0-8-6-2 6-8 6h-6q-10 0-10-9z" fill={INK} />; default: return ( <> <circle cx="-13" cy={mood === "sad" ? 2 : 0} r="5" fill={INK} /> <circle cx="13" cy={mood === "sad" ? 2 : 0} r="5" fill={INK} /> </> ); } })(); const mouth = (() => { switch (mood) { case "grin": case "squint": return <path d="M-14 12h28q0 15-14 15t-14-15z" fill={INK} />; case "sleepy": case "shy": return <path d="M-5 17q5 4 10 0" stroke={INK} strokeWidth="3.5" fill="none" strokeLinecap="round" />; case "surprised": return <ellipse cx="0" cy="19" rx="5.5" ry="7" fill={INK} />; case "meh": return <path d="M-9 18h18" stroke={INK} strokeWidth="4" strokeLinecap="round" />; case "sad": return <path d="M-10 22q10-9 20 0" stroke={INK} strokeWidth="4" fill="none" strokeLinecap="round" />; case "cool": return <path d="M-8 18q9 5 16-3" stroke={INK} strokeWidth="4" fill="none" strokeLinecap="round" />; default: return <path d="M-12 14q12 12 24 0" stroke={INK} strokeWidth="4" fill="none" strokeLinecap="round" />; } })(); const blush = mood === "happy" || mood === "shy" || mood === "grin"; return ( <svg viewBox="-50 -50 100 100" className="absolute inset-0 size-full" aria-hidden="true"> <circle r="44" fill="none" stroke="#ffffff" strokeOpacity="0.28" strokeWidth="3" /> <path d="M-30-22a38 38 0 0118-14" stroke="#ffffff" strokeOpacity="0.55" strokeWidth="5" strokeLinecap="round" fill="none" /> <g transform="translate(0 -6)"> <g data-eyes="">{eyes}</g> </g> {blush ? ( <> <ellipse cx="-24" cy="12" rx="6" ry="3.5" fill="#ff5e8a" opacity="0.45" /> <ellipse cx="24" cy="12" rx="6" ry="3.5" fill="#ff5e8a" opacity="0.45" /> </> ) : null} {mouth} </svg> ); } const shade = (hex: string, k: number) => { const n = Number.parseInt(hex.replace("#", "").padEnd(6, "0").slice(0, 6), 16); const c = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((v) => Math.round(v * k)); return `rgb(${c.join(",")})`; }; type Mode = "line" | "held" | "flung" | "return"; interface CoinState { agent: number; mode: Mode; x: number; y: number; vx: number; vy: number; angle: number; spin: number; since: number; flip: number; flipMood: CoinMood | null; wobble: number; wobbleV: number; blinkAt: number; /** When the current fling or return started, in seconds. */ t0: number; from: { x: number; y: number; angle: number }; } /** * A hero card for a fictional agent product with a diagonal parade of chunky coin characters rolling * up and out of the card. Coins near the bottom are larger; toward the top they shrink and tilt to * show more rim. The line moves like a conveyor, each coin bobbing as it rolls; they blink and their * eyes follow the pointer. Hover squishes a coin, a poke flips it over to a new mood and bumps its * neighbors, and a coin can be dragged out of the line and flung: it tumbles off the card's edges and * rejoins the line at its gap. * * The parade is decorative (aria-hidden); the headline, description and CTA are the content, and a * visually hidden "Poke a coin" button lets keyboard users poke one too. One rAF loop writes every * transform. Reduced motion: a still diagonal, and a poke only swaps the face. */ export function CoinCrewHero({ title = "Meet your new team", description = ( <> Eight small agents that file, follow up and tidy up. <br /> They never sleep, but they do blink. </> ), cta = { label: "Hire the crew", href: "#" }, agents, palette = PALETTE, speed = 1, seed = "crew", onPoke, className, ...props }: CoinCrewHeroProps) { const reduceMotion = useReducedMotion(); const crew: CoinAgent[] = agents ?? (() => { const r = createRandom(`${seed}:crew`); return Array.from({ length: 12 }, (_, i) => ({ color: palette[i % palette.length], mood: r.pick(MOODS) })); })(); const crewKey = JSON.stringify(crew); // Which agent sits in each slot, and the face each slot shows right now. const [slots, setSlots] = useState(() => Array.from({ length: SLOTS }, (_, i) => ({ agent: i % crew.length, mood: crew[i % crew.length].mood }))); const cardRef = useRef<HTMLElement>(null); const coinRefs = useRef<(HTMLDivElement | null)[]>([]); const engine = useRef<{ poke: (slot: number) => void } | null>(null); const live = useRef({ speed, onPoke, crew, reduceMotion, slots }); useEffect(() => { live.current = { speed, onPoke, crew, reduceMotion, slots }; }); useEffect(() => { const card = cardRef.current; if (!card) return; const rng = createRandom(`${seed}:life`); let width = 1; let height = 1; let base = 100; let belt = 0; let raf = 0; let last = 0; let clock = 0; let inView = true; const pointer = { x: -1e4, y: -1e4, inside: false }; const state: CoinState[] = Array.from({ length: SLOTS }, (_, i) => ({ agent: i % live.current.crew.length, mode: "line" as Mode, x: 0, y: 0, vx: 0, vy: 0, angle: 0, spin: 0, since: 0, flip: -1, flipMood: null, wobble: 0, wobbleV: 0, blinkAt: rng.range(1, 6), t0: 0, from: { x: 0, y: 0, angle: 0 }, })); let drag: { slot: number; dx: number; dy: number; lastX: number; lastY: number; lastT: number; vx: number; vy: number; moved: boolean; startX: number; startY: number } | null = null; /** Where slot i sits on the belt: position along the diagonal, size and tilt. */ const place = (i: number) => { const s = (((i + belt) / SLOTS) % 1 + 1) % 1; const x = width * (0.36 + s * 0.8); const y = height * (1.14 - s * 1.34); const size = base * (1.15 - s * 0.62); return { s, x, y, size }; }; const measure = () => { width = card.clientWidth; height = card.clientHeight; base = Math.max(64, Math.min(200, Math.min(width * 0.235, height * 0.42))); card.style.setProperty("--coin", `${base}px`); }; const setMood = (slot: number, mood: CoinMood) => setSlots((list) => list.map((s, i) => (i === slot ? { ...s, mood } : s))); const poke = (slot: number) => { const c = state[slot]; const current = live.current.slots[slot]?.mood ?? "happy"; const next = MOODS[(MOODS.indexOf(current) + 1 + rng.int(0, MOODS.length - 2)) % MOODS.length]; const { crew: list, onPoke: cb, reduceMotion: still } = live.current; cb?.(list[c.agent % list.length], slot); if (still) { setMood(slot, next); return; } c.flip = 0; c.flipMood = next; // Neighbors on the belt get a nudge. for (const n of [slot - 1, slot + 1]) { const o = state[(n + SLOTS) % SLOTS]; if (o) o.wobbleV += n < slot ? -6 : 6; } wake(); }; engine.current = { poke }; const frame = (now: number) => { raf = 0; const dt = last ? Math.min(0.05, (now - last) / 1000) : 0; last = now; const still = live.current.reduceMotion; if (!still) { clock += dt; belt += dt * 0.085 * live.current.speed; } const order: { i: number; s: number }[] = []; for (let i = 0; i < SLOTS; i++) { const el = coinRefs.current[i]; const c = state[i]; if (!el) continue; const p = place(i); // A coin that left the top re-enters at the bottom as the next agent in the crew. if (!still && c.mode === "line" && p.s < 0.02 && c.since > 1) { const list = live.current.crew; c.agent = (c.agent + SLOTS) % list.length; setSlots((cur) => cur.map((s, k) => (k === i ? { agent: c.agent, mood: list[c.agent].mood } : s))); c.since = 0; } c.since += dt; const bob = still ? 0 : Math.sin(clock * 3.1 + i * 1.7) * base * 0.025; const roll = still ? 0 : Math.sin(clock * 1.6 + i) * 6; // Neighbor wobble: a damped spring on a small angle. c.wobbleV += (-c.wobble * 90 - c.wobbleV * 9) * dt; c.wobble += c.wobbleV * dt; let x = p.x; let y = p.y + bob; let angle = roll + c.wobble; if (c.mode === "held") { x = c.x; y = c.y; angle = c.angle; } else if (c.mode === "flung") { c.vy += 2600 * dt; c.x += c.vx * dt; c.y += c.vy * dt; c.angle += c.spin * dt; const r = p.size * 0.5; // Bounce off the card's edges, losing a little energy each time. if (c.x < r) { c.x = r; c.vx = Math.abs(c.vx) * 0.55; c.spin *= -0.7; } if (c.x > width - r) { c.x = width - r; c.vx = -Math.abs(c.vx) * 0.55; c.spin *= -0.7; } if (c.y < r) { c.y = r; c.vy = Math.abs(c.vy) * 0.5; } if (c.y > height - r) { c.y = height - r; c.vy = -Math.abs(c.vy) * 0.5; c.vx *= 0.8; c.spin *= 0.8; } if (now / 1000 - c.t0 > 1.5) { c.mode = "return"; c.from = { x: c.x, y: c.y, angle: c.angle }; c.t0 = now / 1000; } x = c.x; y = c.y; angle = c.angle; } else if (c.mode === "return") { const t = Math.min(1, (now / 1000 - c.t0) / 0.55); const e = 1 - (1 - t) ** 3; x = c.from.x + (x - c.from.x) * e; y = c.from.y + (y - c.from.y) * e; angle = c.from.angle * (1 - e) + angle * e; if (t >= 1) c.mode = "line"; } // Flip through edge-on; the new face appears at the halfway point. let flipScale = 1; if (c.flip >= 0) { c.flip += dt / 0.55; if (c.flip >= 0.5 && c.flipMood) { setMood(i, c.flipMood); c.flipMood = null; } flipScale = Math.cos(Math.min(1, c.flip) * Math.PI * 2); if (c.flip >= 1) c.flip = -1; } // Hover squish, and eyes that look at the pointer. const dx = pointer.x - x; const dy = pointer.y - y; const dist = Math.hypot(dx, dy); const hovered = pointer.inside && dist < p.size * 0.5 && c.mode === "line"; const sq = hovered && !still ? 0.06 : 0; const look = pointer.inside && !still ? Math.min(1, dist / (p.size * 0.6)) : 0; const eyes = el.querySelector<SVGGElement>("[data-eyes]"); // Blink now and then. let blink = 1; if (!still) { if (clock > c.blinkAt) { if (clock > c.blinkAt + 0.14) c.blinkAt = clock + rng.range(1.8, 6); else blink = 0.12; } } if (eyes) eyes.setAttribute("transform", `translate(${dist ? ((dx / dist) * 5 * look).toFixed(2) : 0} ${dist ? ((dy / dist) * 4 * look).toFixed(2) : 0}) scale(1 ${blink})`); const tilt = p.s; const scale = p.size / base; const rim = base * 0.075 * (1 + tilt * 0.9); el.style.setProperty("--rx", `${((rim / RIM) * 0.55).toFixed(2)}px`); el.style.setProperty("--ry", `${((rim / RIM) * 0.85).toFixed(2)}px`); el.style.transform = `translate3d(${(x - base / 2).toFixed(1)}px, ${(y - base / 2).toFixed(1)}px, 0) rotate(${angle.toFixed(2)}deg) scale(${(scale * (1 + sq) * Math.max(0.04, Math.abs(flipScale))).toFixed(3)}, ${(scale * (1 - sq) * (1 - tilt * 0.09)).toFixed(3)})`; el.style.opacity = "1"; order.push({ i, s: c.mode === "line" ? p.s : -1 }); } // Bigger (lower) coins in front; a lifted or tumbling coin above them all. order.sort((a, b) => b.s - a.s).forEach((o, k) => { const el = coinRefs.current[o.i]; if (el) el.style.zIndex = String(k + 1); }); if (inView && !document.hidden && !still) raf = requestAnimationFrame(frame); }; const wake = () => { if (!raf) { last = 0; raf = requestAnimationFrame(frame); } }; const local = (e: PointerEvent) => { const b = card.getBoundingClientRect(); return { x: e.clientX - b.left, y: e.clientY - b.top }; }; const onMove = (e: PointerEvent) => { const p = local(e); pointer.x = p.x; pointer.y = p.y; pointer.inside = true; if (drag) { const c = state[drag.slot]; if (!drag.moved && Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY) > 6) { drag.moved = true; c.mode = "held"; c.angle = 0; } if (drag.moved) { const t = performance.now(); const dtm = Math.max(1, t - drag.lastT) / 1000; drag.vx = (p.x - drag.lastX) / dtm; drag.vy = (p.y - drag.lastY) / dtm; drag.lastX = p.x; drag.lastY = p.y; drag.lastT = t; c.x = p.x - drag.dx; c.y = p.y - drag.dy; c.angle += drag.vx * 0.004; } } if (live.current.reduceMotion) return; wake(); }; const onLeave = () => { pointer.inside = false; }; const onUp = () => { if (!drag) return; const d = drag; drag = null; const c = state[d.slot]; if (!d.moved) { poke(d.slot); return; } c.mode = "flung"; c.vx = Math.max(-2600, Math.min(2600, d.vx)); c.vy = Math.max(-2600, Math.min(2600, d.vy)); c.spin = c.vx * 0.5; c.t0 = performance.now() / 1000; wake(); }; const onDown = (e: PointerEvent) => { const target = (e.target as HTMLElement).closest<HTMLElement>("[data-coin]"); if (!target) return; const slot = Number(target.dataset.coin); const c = state[slot]; if (c.mode !== "line") return; const p = local(e); const at = place(slot); if (live.current.reduceMotion) { poke(slot); return; } e.preventDefault(); drag = { slot, dx: p.x - at.x, dy: p.y - at.y, lastX: p.x, lastY: p.y, lastT: performance.now(), vx: 0, vy: 0, moved: false, startX: e.clientX, startY: e.clientY }; c.x = at.x; c.y = at.y; }; measure(); const ro = new ResizeObserver(() => { measure(); wake(); }); ro.observe(card); const io = new IntersectionObserver(([entry]) => { inView = entry?.isIntersecting ?? true; wake(); }); io.observe(card); const onVisibility = () => wake(); document.addEventListener("visibilitychange", onVisibility); card.addEventListener("pointermove", onMove); card.addEventListener("pointerleave", onLeave); card.addEventListener("pointerdown", onDown); window.addEventListener("pointerup", onUp); window.addEventListener("pointercancel", onUp); wake(); return () => { cancelAnimationFrame(raf); engine.current = null; ro.disconnect(); io.disconnect(); document.removeEventListener("visibilitychange", onVisibility); card.removeEventListener("pointermove", onMove); card.removeEventListener("pointerleave", onLeave); card.removeEventListener("pointerdown", onDown); window.removeEventListener("pointerup", onUp); window.removeEventListener("pointercancel", onUp); }; }, [seed, crewKey]); const pokeRandom = () => engine.current?.poke(Math.floor((Date.now() / 997) % SLOTS)); const ctaClass = "inline-flex h-11 w-fit cursor-pointer items-center rounded-full bg-foreground px-5 text-[15px] font-semibold text-background outline-none transition-[background-color,transform] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-surface-2 active:scale-[0.96] [@media(hover:hover)]:hover:bg-foreground/85"; return ( <section ref={cardRef} className={cn("@container relative isolate flex min-h-[26rem] w-full touch-pan-y flex-col overflow-hidden rounded-[28px] bg-surface-2 p-8 select-none sm:p-10", className)} {...props} > {/* On a narrow card the copy wraps before the coin line instead of running over the coins. */} <div className="relative z-[60] flex max-w-[min(22rem,60cqw)] flex-col gap-4"> <h2 className="text-[clamp(2rem,5vw,3rem)] leading-[1.02] font-semibold tracking-[-0.025em] text-balance text-foreground">{title}</h2> <p className="text-[15px] leading-relaxed text-text-secondary">{description}</p> {cta.href !== undefined ? ( <a href={cta.href} onClick={cta.onClick} className={cn(ctaClass, "mt-2")}> {cta.label} </a> ) : ( <button type="button" onClick={cta.onClick} className={cn(ctaClass, "mt-2")}> {cta.label} </button> )} </div> <div aria-hidden="true" className="absolute inset-0"> {slots.map((slot, i) => { const agent = crew[slot.agent % crew.length]; const rim = shade(agent.color, 0.68); return ( <div key={i} data-coin={i} ref={(el) => { coinRefs.current[i] = el; }} className="absolute top-0 left-0 cursor-grab opacity-0 active:cursor-grabbing" style={{ width: "var(--coin)", height: "var(--coin)", willChange: "transform" }} > {Array.from({ length: RIM }, (_, k) => ( <span key={k} className="absolute inset-0 rounded-full" style={{ background: k === RIM - 1 ? shade(agent.color, 0.5) : rim, transform: `translate(calc(var(--rx) * ${RIM - k}), calc(var(--ry) * ${RIM - k}))` }} /> ))} <span className="absolute inset-0 overflow-hidden rounded-full" style={{ background: agent.color }}> <Face mood={slot.mood} /> </span> </div> ); })} </div> <button type="button" onClick={pokeRandom} className="sr-only z-[70] cursor-pointer rounded-full bg-background px-4 py-2 text-[13px] font-semibold text-foreground focus:not-sr-only focus:absolute focus:right-5 focus:bottom-5 focus:ring-2 focus:ring-ring" > Poke a coin </button> </section> ); }
Usage
import { CoinCrewHero } from "@/components/ui/coin-crew-hero/coin-crew-hero";
<CoinCrewHero
title="Meet your new team"
description="Eight small agents that file, follow up and tidy up."
cta={{ label: "Hire the crew", href: "/signup" }}
onPoke={(agent) => console.log("poked", agent.mood)}
/>API reference
| Prop | Type | Default | Description |
|---|---|---|---|
title | ReactNode | "Meet your new team" | Headline, top left. |
description | ReactNode | – | Two-line description under the headline. |
cta | { label: string; href?: string; onClick?: () => void } | { label: "Hire the crew", href: "#" } | The black pill call to action. Pass `href` for a link or `onClick` for a button. |
agents | CoinAgent[] | a seeded crew drawn from `palette` | The coins in the parade, in order. |
palette | string[] | eight bright flats | Colors for the default crew. |
speed | number | 1 | Conveyor speed multiplier. |
seed | string | number | "crew" | Seed for the default crew, blinks and new moods. |
onPoke | (agent: CoinAgent, index: number) => void | – | Called when a coin is poked (clicked, tapped or via the "Poke a coin" button). |
Also accepts every native <section> attribute, including ref and className.
- Each coin is a flat face disc over eight stacked rim layers offset down and to the right, so it reads as a thick disc; toward the top of the card the coins shrink, squash a little and show more rim, like a belt receding into the distance.
- One rAF loop places every coin on the diagonal belt, bobs and rolls it, blinks it at random and points its eyes at the pointer, writing transforms directly (no per-frame React renders). A coin that rolls off the top re-enters at the bottom as the next agent in the crew.
- A poke flips the coin through edge-on (scaleX follows a cosine) and swaps its face at the halfway point, then nudges its neighbors, which wobble on a damped spring. Dragging lifts a coin out of the line; on release it tumbles with gravity and spin, bounces off the card's edges and eases back into its moving slot.
- Faces are inline SVG with ten moods (happy, grin, wink, sleepy, squint, surprised, meh, sad, cool and shy). Without `agents`, the crew and its moods come from `seed` and `palette`.
Accessibility
- The headline, description and call to action are the content. The coin parade is decorative and hidden from assistive tech.
- A visually hidden "Poke a coin" button (it appears when focused) pokes a coin, so keyboard users get the joke too.
- Under reduced motion the coins sit still on the diagonal, a poke only swaps the face, and dragging is off. The loop pauses offscreen and in hidden tabs.