A railway departure board hung from two steel rods, used as a countdown. Three rows of flap cards sit in a black window behind a pane of glass: a line of twelve letters for a message, eight tall number cards for days, hours, minutes and seconds, and a small line that spells out the date and time of departure. Nothing fades or slides. A card can only change by dropping to the next one on its drum, so a new message arrives as a ripple of wrong letters that settles from left to right, and when a minute rolls over the tens of seconds rattle through five cards to get from 0 to 5. Move a pointer across the letters and the cards under it flutter. Press the board and a wave spreads from that spot. The same countdown is written as plain text under the board, followed by one button. The drawing keeps its proportions and scales to the column, so on a phone it is the same board at about 330 pixels wide.
import { useEffect, useRef, useState } from "react";
// When the countdown ends, in the visitor's local time ("2027-03-01T09:00:00").
// Left empty, or once the date has passed, the board counts to DEMO_DAYS after the first view.
const TARGET = "";
const DEMO_DAYS = 12.3212;
// The top row turns through these. Twelve characters at most, capitals, digits and spaces.
const messages = ["LAUNCHING IN", "ATLAS TWO", "ALL ABOARD"];
const EVENT = "Atlas Two";
const W = 860, H = 560, OX = 50; // drawing size and the left margin kept for the shadow; the canvas scales to its box
const L_DRUM = " ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789:"; // order of the flaps on a letter drum
const D_DRUM = "9876543210"; // number drums are wound backwards, so counting down is one flap forward
const SIZES = [[50, 62], [70, 104], [37, 48]]; // flap width and height for the message, number and date rows
const DAYS = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"];
const MONTHS = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
type Cell = { x: number; y: number; kind: number; n: number; shown: number; next: number; goal: number; left: number; p: number; dur: number; wait: number; kicked: number };
const pad2 = (v: number) => String(v).padStart(2, "0");
const mod = (a: number, n: number) => ((a % n) + n) % n;
const centre = (text: string, width: number) => { const t = text.slice(0, width); const l = Math.floor((width - t.length) / 2); return (" ".repeat(l) + t).padEnd(width, " "); };
const split = (ms: number) => { const s = Math.max(0, Math.floor(ms / 1000)); return { d: Math.min(99, Math.floor(s / 86400)), h: Math.floor(s / 3600) % 24, m: Math.floor(s / 60) % 60, s: s % 60 }; };
export default function SplitFlapStationBoardCountdown() {
const canvas = useRef<HTMLCanvasElement>(null);
const api = useRef<{ shuffle: (x: number, y: number) => void } | null>(null);
const [info, setInfo] = useState<{ left: number; when: string } | null>(null);
useEffect(() => {
const cv = canvas.current;
if (!cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const parsed = TARGET ? new Date(TARGET).getTime() : NaN;
const end = Number.isFinite(parsed) && parsed > Date.now() ? parsed : Math.ceil((Date.now() + DEMO_DAYS * 86400000) / 3600000) * 3600000;
const ed = new Date(end);
const dateLine = `${DAYS[ed.getDay()]} ${pad2(ed.getDate())} ${MONTHS[ed.getMonth()]} ${pad2(ed.getHours())}:${pad2(ed.getMinutes())}`;
const when = `${DAYS[ed.getDay()]} ${ed.getDate()} ${MONTHS[ed.getMonth()]}, ${pad2(ed.getHours())}:${pad2(ed.getMinutes())}`;
// Every flap position on the board.
const cells: Cell[] = [];
const row = (kind: number, count: number, x0: number, y: number, gap: number, pairGap: number) => {
const drum = kind === 1 ? D_DRUM : L_DRUM;
let x = x0;
for (let i = 0; i < count; i++) {
cells.push({ x: x + OX, y, kind, n: drum.length, shown: kind === 1 ? (i * 7 + 3) % 10 : 0, next: 0, goal: 0, left: 0, p: -1, dur: 60, wait: 0, kicked: 0 });
x += SIZES[kind][0] + (pairGap && i % 2 === 1 ? pairGap : gap);
}
};
row(0, 12, 52.5, 134, 5, 0);
row(1, 8, 51, 212, 5, 26);
row(2, 16, 54, 352, 4, 0);
const top = cells.slice(0, 12), nums = cells.slice(12, 20), date = cells.slice(20);
const aim = (c: Cell, idx: number, spin: number, wait: number) => {
if (reduce) { c.shown = idx; c.goal = idx; c.left = 0; c.p = -1; return; }
const flying = c.p >= 0;
const fwd = mod(idx - (flying ? c.next : c.shown), c.n);
c.goal = idx;
c.left = (c.kind === 1 ? (fwd === 0 && spin ? c.n : fwd) : fwd === 0 && !spin ? 0 : Math.max(1, spin)) + (flying ? 1 : 0);
if (!flying) c.wait = wait;
};
const write = (set: Cell[], text: string, spin: number, stagger: number, base: number) =>
set.forEach((c, i) => { const idx = Math.max(0, (c.kind === 1 ? D_DRUM : L_DRUM).indexOf(text[i] || " ")); if (idx !== c.goal || spin) aim(c, idx, spin ? spin + (i % 3) : 0, base + i * stagger); });
let S = 1, bg: HTMLCanvasElement | null = null;
let atlas: { a: HTMLCanvasElement; tw: number; th: number }[] = [];
const rr = (g: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) => { g.beginPath(); g.moveTo(x + r, y); g.arcTo(x + w, y, x + w, y + h, r); g.arcTo(x + w, y + h, x, y + h, r); g.arcTo(x, y + h, x, y, r); g.arcTo(x, y, x + w, y, r); g.closePath(); };
// One flap card: matt black, a hinge across the middle, the pins that carry it at either side.
const paintFlap = (g: CanvasRenderingContext2D, w: number, h: number, ch: string) => {
rr(g, 0, 0, w, h, Math.min(4, w * 0.08)); g.save(); g.clip();
const a = g.createLinearGradient(0, 0, 0, h / 2); a.addColorStop(0, "#303135"); a.addColorStop(1, "#1f2023");
g.fillStyle = a; g.fillRect(0, 0, w, h / 2);
const b = g.createLinearGradient(0, h / 2, 0, h); b.addColorStop(0, "#111213"); b.addColorStop(0.22, "#1b1c1e"); b.addColorStop(1, "#27282b");
g.fillStyle = b; g.fillRect(0, h / 2, w, h / 2);
const px = h * 0.74;
g.font = `700 ${px}px "Helvetica Neue", Helvetica, Arial, sans-serif`; g.textAlign = "center"; g.textBaseline = "alphabetic";
const squeeze = Math.min(0.88, (w * 0.76) / Math.max(1, g.measureText(ch).width));
g.save(); g.translate(w / 2, h / 2 + px * 0.36); g.scale(squeeze, 1); g.fillStyle = "#f4f0e4"; g.fillText(ch, 0, 0); g.restore();
g.fillStyle = "#050506"; g.fillRect(0, h / 2 - 0.9, w, 1.8);
g.fillStyle = "rgba(255,255,255,0.09)"; g.fillRect(0, h / 2 + 0.9, w, 0.8);
g.fillStyle = "rgba(255,255,255,0.16)"; g.fillRect(0, 0, w, 0.9);
g.fillStyle = "#050506"; g.fillRect(0, h / 2 - h * 0.055, w * 0.05, h * 0.11); g.fillRect(w * 0.95, h / 2 - h * 0.055, w * 0.05, h * 0.11);
g.restore();
};
const build = () => {
const box = cv.getBoundingClientRect();
S = (Math.max(280, box.width) / W) * Math.min(2, window.devicePixelRatio || 1);
cv.width = Math.round(W * S); cv.height = Math.round(H * S);
atlas = SIZES.map(([cw, ch], kind) => {
const drum = kind === 1 ? D_DRUM : L_DRUM, tw = Math.round(cw * S), th = Math.round((ch * S) / 2) * 2;
const a = document.createElement("canvas"); a.width = tw * drum.length; a.height = th;
const g = a.getContext("2d");
if (g) for (let i = 0; i < drum.length; i++) { g.save(); g.translate(i * tw, 0); g.scale(tw / cw, th / ch); paintFlap(g, cw, ch, drum[i]); g.restore(); }
return { a, tw, th };
});
// The housing never changes, so it is painted once.
bg = document.createElement("canvas"); bg.width = cv.width; bg.height = cv.height;
const g = bg.getContext("2d");
if (!g) return;
g.scale(S, S); g.translate(OX, 0);
// Shadow on the wall behind the board.
const OFF = 4000;
g.save(); g.shadowColor = "rgba(48,40,26,0.4)"; g.shadowBlur = 34 * S; g.shadowOffsetX = OFF * S; g.shadowOffsetY = 34 * S; rr(g, 40 - OFF, 96, 680, 338, 14); g.fill(); g.restore();
g.save(); g.shadowColor = "rgba(40,32,20,0.5)"; g.shadowBlur = 12 * S; g.shadowOffsetX = OFF * S; g.shadowOffsetY = 9 * S; rr(g, 24 - OFF, 74, 712, 360, 12); g.fill(); g.restore();
// Two rods to the ceiling.
for (const x of [150, 610]) {
const rod = g.createLinearGradient(x - 5, 0, x + 5, 0); rod.addColorStop(0, "#3a3c40"); rod.addColorStop(0.35, "#b9bcc1"); rod.addColorStop(0.55, "#74777c"); rod.addColorStop(1, "#1e2023");
g.fillStyle = rod; g.fillRect(x - 5, 0, 10, 72);
const col = g.createLinearGradient(x - 12, 0, x + 12, 0); col.addColorStop(0, "#26282b"); col.addColorStop(0.35, "#8d9096"); col.addColorStop(1, "#141517");
g.fillStyle = col; rr(g, x - 12, 60, 24, 12, 2); g.fill();
}
// Steel body with a brushed grain.
rr(g, 20, 70, 720, 364, 12);
const body = g.createLinearGradient(0, 70, 0, 434); body.addColorStop(0, "#3a3d41"); body.addColorStop(0.12, "#2b2d31"); body.addColorStop(1, "#1b1c1f");
g.fillStyle = body; g.fill();
g.save(); g.clip();
let seed = 11; const rand = () => { seed = (seed * 16807) % 2147483647; return seed / 2147483647; };
for (let y = 70; y < 434; y += 0.7) { const v = rand(); g.fillStyle = v > 0.5 ? `rgba(255,255,255,${(v - 0.5) * 0.07})` : `rgba(0,0,0,${(0.5 - v) * 0.16})`; g.fillRect(20 + rand() * 60, y, 720, 0.6); }
const wash = g.createRadialGradient(380, 40, 20, 380, 70, 460); wash.addColorStop(0, "rgba(255,244,222,0.16)"); wash.addColorStop(1, "rgba(255,244,222,0)");
g.fillStyle = wash; g.fillRect(20, 70, 720, 364);
g.restore();
g.lineWidth = 1; g.strokeStyle = "#0b0c0d"; rr(g, 20.5, 70.5, 719, 363, 12); g.stroke();
const lip = g.createLinearGradient(0, 70, 0, 434); lip.addColorStop(0, "rgba(255,255,255,0.5)"); lip.addColorStop(0.08, "rgba(255,255,255,0.05)"); lip.addColorStop(0.9, "rgba(0,0,0,0)"); lip.addColorStop(1, "rgba(0,0,0,0.5)");
g.strokeStyle = lip; g.lineWidth = 1.2; rr(g, 21.6, 71.6, 716.8, 360.8, 11); g.stroke();
// The window the flaps sit in.
rr(g, 38, 118, 684, 298, 7); g.fillStyle = "#08090a"; g.fill();
g.save(); g.clip();
const inner = g.createLinearGradient(0, 118, 0, 150); inner.addColorStop(0, "rgba(0,0,0,0.95)"); inner.addColorStop(1, "rgba(0,0,0,0)");
g.fillStyle = "#101113"; g.fillRect(38, 118, 684, 298); g.fillStyle = inner; g.fillRect(38, 118, 684, 32);
g.restore();
g.lineWidth = 1; g.strokeStyle = "rgba(255,255,255,0.16)"; g.beginPath(); g.moveTo(44, 416.5); g.lineTo(716, 416.5); g.stroke();
g.strokeStyle = "rgba(0,0,0,0.9)"; rr(g, 37.5, 117.5, 685, 299, 7); g.stroke();
// Printed legends.
g.textBaseline = "middle"; g.fillStyle = "rgba(240,236,224,0.86)";
const spaced = (text: string, x: number, y: number, gap: number, align: number) => { let w = 0; for (const ch of text) w += g.measureText(ch).width + gap; let cx = x - (w - gap) * align; g.textAlign = "left"; for (const ch of text) { g.fillText(ch, cx, y); cx += g.measureText(ch).width + gap; } };
g.font = '700 12px "Helvetica Neue", Helvetica, Arial, sans-serif'; spaced("DEPARTURES", 56, 95, 3.2, 0);
g.fillStyle = "rgba(240,236,224,0.5)"; g.font = '600 10px "Helvetica Neue", Helvetica, Arial, sans-serif'; spaced("TRACK 2", 690, 95, 2.4, 1);
g.fillStyle = "rgba(240,236,224,0.62)"; g.font = '600 9.5px "Helvetica Neue", Helvetica, Arial, sans-serif';
["DAYS", "HOURS", "MINUTES", "SECONDS"].forEach((label, i) => spaced(label, 51 + 72.5 + i * 171, 334, 2.6, 0.5));
// Slot-head screws in the corners.
for (const [x, y, a] of [[36, 86, 0.5], [724, 86, 2.2], [36, 422, 1.4], [724, 422, 0.2]]) {
const sc = g.createRadialGradient(x - 1.5, y - 1.5, 0.5, x, y, 5.5); sc.addColorStop(0, "#d9dbde"); sc.addColorStop(0.6, "#7c7f85"); sc.addColorStop(1, "#2a2c2f");
g.beginPath(); g.arc(x, y, 5, 0, Math.PI * 2); g.fillStyle = sc; g.fill(); g.lineWidth = 0.8; g.strokeStyle = "rgba(0,0,0,0.8)"; g.stroke();
g.lineWidth = 1.3; g.strokeStyle = "rgba(10,10,12,0.85)"; g.beginPath(); g.moveTo(x - Math.cos(a) * 3.6, y - Math.sin(a) * 3.6); g.lineTo(x + Math.cos(a) * 3.6, y + Math.sin(a) * 3.6); g.stroke();
}
// Wells behind each flap, with the edges of the waiting cards showing above it.
for (const c of cells) {
const [cw, ch] = SIZES[c.kind];
const x = c.x - OX;
g.fillStyle = "#000"; rr(g, x - 1.5, c.y - 5, cw + 3, ch + 8, 4); g.fill();
for (let k = 0; k < 3; k++) { g.fillStyle = `rgba(255,255,255,${0.1 - k * 0.028})`; g.fillRect(x + 1 + k, c.y - 1.6 - k * 1.3, cw - 2 - k * 2, 0.7); }
}
};
const draw = (t: number) => {
if (!bg) return;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, cv.width, cv.height);
ctx.drawImage(bg, 0, 0);
for (const c of cells) {
const { a, tw, th } = atlas[c.kind], hh = th / 2, x = Math.round(c.x * S), y = Math.round(c.y * S);
if (c.p < 0) { ctx.drawImage(a, c.shown * tw, 0, tw, th, x, y, tw, th); continue; }
ctx.drawImage(a, c.next * tw, 0, tw, hh, x, y, tw, hh);
ctx.drawImage(a, c.shown * tw, hh, tw, hh, x, y + hh, tw, hh);
const e = c.p * c.p * (1.6 - 0.6 * c.p); // the card speeds up as it falls
if (e < 0.5) {
const k = Math.cos(e * Math.PI), fh = Math.max(1, hh * k);
ctx.fillStyle = `rgba(0,0,0,${0.5 * (1 - k)})`; ctx.fillRect(x, y + hh, tw, hh * (1 - k) * 0.9);
ctx.drawImage(a, c.shown * tw, 0, tw, hh, x - (1 - k) * S, y + hh - fh, tw + (1 - k) * 2 * S, fh);
ctx.fillStyle = `rgba(255,255,255,${0.1 * (1 - k)})`; ctx.fillRect(x, y + hh - fh, tw, fh);
} else {
const k = -Math.cos(e * Math.PI), fh = Math.max(1, hh * k);
ctx.drawImage(a, c.next * tw, hh, tw, hh, x - (1 - k) * S, y + hh, tw + (1 - k) * 2 * S, fh);
ctx.fillStyle = `rgba(0,0,0,${0.55 * (1 - k)})`; ctx.fillRect(x, y + hh, tw, fh);
}
}
ctx.setTransform(S, 0, 0, S, OX * S, 0);
// The amber lamp that says the board is live.
const on = reduce ? 1 : 0.6 + 0.4 * Math.sin(t / 520);
ctx.beginPath(); ctx.arc(706, 95, 5.4, 0, Math.PI * 2); ctx.fillStyle = "#070708"; ctx.fill();
ctx.save(); ctx.shadowColor = "#ffab2e"; ctx.shadowBlur = 16 * on * S; ctx.globalAlpha = 0.3 + 0.7 * on;
ctx.beginPath(); ctx.arc(706, 95, 3.6, 0, Math.PI * 2); ctx.fillStyle = "#ffb13b"; ctx.fill(); ctx.restore();
ctx.beginPath(); ctx.arc(704.8, 93.8, 1.1, 0, Math.PI * 2); ctx.fillStyle = "rgba(255,255,255,0.7)"; ctx.fill();
// A pane of glass over the window.
const glass = ctx.createLinearGradient(120, 118, 420, 416); glass.addColorStop(0, "rgba(255,255,255,0.075)"); glass.addColorStop(0.42, "rgba(255,255,255,0.02)"); glass.addColorStop(0.43, "rgba(255,255,255,0)"); glass.addColorStop(1, "rgba(255,255,255,0.03)");
ctx.fillStyle = glass; rr(ctx, 38, 118, 684, 298, 7); ctx.fill();
};
let msg = 0, lastDigits = "", lastSec = -1, raf = 0, last = 0, t0 = 0, seen = true, nextMsg = 5200, touched = -1e9;
const sync = (first: boolean) => {
const leftMs = end - Date.now(), v = split(leftMs), digits = pad2(v.d) + pad2(v.h) + pad2(v.m) + pad2(v.s);
if (digits !== lastDigits) { lastDigits = digits; nums.forEach((c, i) => aim(c, D_DRUM.indexOf(digits[i]), 0, first ? 160 + i * 46 : 0)); }
const sec = Math.floor(Math.max(0, leftMs) / 1000);
if (sec !== lastSec) { lastSec = sec; setInfo({ left: sec, when }); }
};
const step = (dt: number) => {
for (const c of cells) {
if (c.p < 0) {
if (c.left <= 0) continue;
if (c.wait > 0) { c.wait -= dt; continue; }
c.next = mod(c.goal - (c.left - 1), c.n); c.p = 0; c.dur = c.left > 1 ? 52 + ((c.x * 7) % 16) : 150;
}
c.p += dt / c.dur;
if (c.p >= 1) { c.shown = c.next; c.left -= 1; c.p = -1; }
}
};
const frame = (now: number) => {
raf = 0;
if (!t0) { t0 = now; last = now; }
const dt = Math.min(50, now - last); last = now;
const t = now - t0;
sync(false);
if (t > nextMsg && now - touched > 2500) { msg = (msg + 1) % messages.length; write(top, centre(messages[msg], 12), 5, 34, 0); nextMsg = t + 5200; }
step(dt); draw(t);
if (seen && !document.hidden) raf = requestAnimationFrame(frame);
};
const wake = () => { if (!raf && !reduce) { last = performance.now(); raf = requestAnimationFrame(frame); } };
build();
write(top, centre(messages[0], 12), 6, 26, 40);
write(date, centre(dateLine, 16), 5, 20, 330);
sync(true);
let timer = 0;
if (reduce) { draw(0); timer = window.setInterval(() => { sync(false); draw(0); }, 1000); } else wake();
const at = (e: PointerEvent) => { const b = cv.getBoundingClientRect(); return [((e.clientX - b.left) / b.width) * W, ((e.clientY - b.top) / b.height) * H]; };
const onMove = (e: PointerEvent) => {
if (reduce) return;
const [px, py] = at(e), now = performance.now();
for (const c of cells) {
if (c.kind === 1 || c.left > 0 || now - c.kicked < 900) continue;
const [cw, ch] = SIZES[c.kind];
if (Math.hypot(c.x + cw / 2 - px, c.y + ch / 2 - py) < 46) { c.kicked = now; touched = now; aim(c, c.goal, 4, 0); }
}
};
api.current = {
shuffle: (px, py) => {
touched = performance.now();
for (const c of cells) { const [cw, ch] = SIZES[c.kind]; if (c.left <= 0) aim(c, c.goal, c.kind === 1 ? 1 : 7, Math.hypot(c.x + cw / 2 - px, c.y + ch / 2 - py) * 1.1); }
wake();
},
};
const onDown = (e: PointerEvent) => { const [px, py] = at(e); api.current?.shuffle(px, py); };
cv.addEventListener("pointermove", onMove);
cv.addEventListener("pointerdown", onDown);
const ro = new ResizeObserver(() => { build(); if (reduce) draw(0); });
ro.observe(cv);
const io = new IntersectionObserver(([entry]) => { seen = entry.isIntersecting; if (seen) wake(); });
io.observe(cv);
const onVis = () => { if (!document.hidden) wake(); };
document.addEventListener("visibilitychange", onVis);
return () => {
cancelAnimationFrame(raf); window.clearInterval(timer); api.current = null;
cv.removeEventListener("pointermove", onMove); cv.removeEventListener("pointerdown", onDown);
ro.disconnect(); io.disconnect(); document.removeEventListener("visibilitychange", onVis);
};
}, []);
const v = info ? split(info.left * 1000) : null;
return (
<div className="flex min-h-[520px] w-full flex-col items-center justify-start bg-[#d9d5cb] bg-[radial-gradient(90%_70%_at_50%_0%,#ebe8e0_0%,#d9d5cb_55%,#c9c4b8_100%)] px-4 pb-12 sm:min-h-[760px] sm:px-8">
<button
type="button"
aria-label="Shuffle the departure board"
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); api.current?.shuffle(430, 250); } }}
className="block w-full max-w-[1010px] cursor-pointer touch-manipulation rounded-2xl outline-none focus-visible:ring-2 focus-visible:ring-stone-900 focus-visible:ring-offset-4 focus-visible:ring-offset-[#d9d5cb]"
>
<canvas ref={canvas} aria-hidden="true" className="block aspect-[860/560] w-full" />
</button>
<p role="timer" className="-mt-3 min-h-[20px] text-center font-mono text-[11px] uppercase leading-5 tracking-[0.16em] text-stone-600 sm:-mt-12">
<span className="text-stone-900">{EVENT} leaves {info ? info.when : "soon"}</span>
<span className="mx-2 hidden text-stone-400 sm:inline">/</span>
<span className="block tabular-nums sm:inline">{v ? (info && info.left === 0 ? "Departed" : `${v.d} days ${pad2(v.h)} h ${pad2(v.m)} min ${pad2(v.s)} s`) : "Counting"}</span>
</p>
<a href="#" className="mt-5 inline-flex h-11 items-center rounded-full bg-stone-900 px-6 text-sm font-medium text-stone-50 shadow-[0_1px_0_rgba(255,255,255,0.25)_inset,0_10px_24px_-10px_rgba(40,30,10,0.6)] outline-none transition-transform duration-200 hover:-translate-y-0.5 focus-visible:ring-2 focus-visible:ring-stone-900 focus-visible:ring-offset-2 focus-visible:ring-offset-[#d9d5cb] motion-reduce:transition-none motion-reduce:hover:translate-y-0">
Reserve a seat
</a>
</div>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/split-flap-station-board-countdown-that-clatters-down-to-a-launch-date.json
A flip clock digit has ten cards and shows the next number. A split-flap position carries a whole alphabet on one drum and has to pass every card between the old character and the new one. That is why this board can spell words, and why a change looks like a short burst of scrambled letters before it settles.
The cards only fall one way. With the numbers wound in descending order, taking one second off the clock is a single card. If they ran upwards, every tick would have to spin through nine cards to arrive at the number below.
No. A full turn of a 38 card drum would take over two seconds. Each letter is given a short run of cards that ends on the right one, staggered by column, so a new line lands in well under a second while still looking mechanical.
All eight number cards stop on 0 and the text line reads Departed. The message row keeps turning through its lines. Add a check on `info.left === 0` in the component if you want to swap the button or show a different message at that point.
Each character is painted once into a strip of card images when the board is sized. A frame then copies 36 small rectangles and at most a few half cards that are in mid fall. The loop pauses when the board is off screen or the tab is hidden.
Related: Tear-Off Desk Calendar with Pages That Peel, Curl and Flutter Away, Velvet Tip-up Theatre Seat Picker with Sprung Seats That Fold Down, Brass Number Plates and Reserved Cards, Boarding Pass Call to Action That Tears along Its Perforation When You Book and Drops the Stub.
More call-to-action sections or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.