Loaders
DominoRun
Ivory tiles with real pips stand on an S curve in true 3D. A wave knocks them down one by one, then runs back and stands them up again.
Download
- React
- Tailwind CSS v4
- motion
components/ui/domino-run.tsx
"use client";
import { useEffect, useMemo, useRef, type CSSProperties } from "react";
import { useReducedMotion } from "motion/react";
type DominoRunProps = {
/** Number of tiles in the run. */
tiles?: number;
/** Tile colour. */
tile?: string;
/** Colour of the pips and the centre line. */
pip?: string;
/** 1 is the default pace; 2 is twice as fast. */
speed?: number;
/** Read out by screen readers. */
label?: string;
className?: string;
};
// All sizes are in em and 1em is 1% of the width, so the run scales with its box.
const W = 8;
const H = 16;
const D = 2.1;
const GAP = 5.5; // Floor distance between tiles.
const REST = 0.2;
const clamp = (v: number) => Math.min(1, Math.max(0, v));
const span = (p: number, a: number, b: number) => clamp((p - a) / (b - a));
// Pip spots on a 3 by 3 grid, for faces 0 to 6.
const SPOTS = [[], [4], [0, 8], [0, 4, 8], [0, 2, 6, 8], [0, 2, 4, 6, 8], [0, 2, 3, 5, 6, 8]];
function face(a: number, b: number, pip: string) {
const dot = (spot: number, half: number) => {
const x = 26 + (spot % 3) * 24;
const y = 12 + half * 50 + Math.floor(spot / 3) * 13;
return `radial-gradient(circle at ${x}% ${y}%,${pip} 0.62em,transparent 0.68em)`;
};
return [
...SPOTS[a].map((s) => dot(s, 0)),
...SPOTS[b].map((s) => dot(s, 1)),
`linear-gradient(0deg,transparent calc(50% - 0.12em),${pip} calc(50% - 0.12em),${pip} calc(50% + 0.12em),transparent calc(50% + 0.12em))`,
].join(",");
}
// The floor line: walks an S curve at even steps, so spacing holds round the bends.
function layout(count: number) {
// Starts far away and comes toward the viewer, so the falling tiles show their pips.
const curve = (u: number) => [46 - 92 * u, 15 * Math.sin(u * Math.PI * 2 + 0.4) + 6 * u - 9];
const out: { x: number; y: number; heading: number; bend: number }[] = [];
const total = (count - 1) * GAP;
let [x, y] = curve(0);
let walked = 0;
let next = 0;
for (let u = 0; u <= 1.6 && out.length < count; u += 0.001) {
const [nx, ny] = curve(u);
walked += Math.hypot(nx - x, ny - y);
x = nx;
y = ny;
if (walked >= next) {
const [ax, ay] = curve(u + 0.002);
out.push({ x, y, heading: Math.atan2(ay - y, ax - x), bend: walked / Math.max(total, 1) });
next += GAP;
}
}
return out;
}
// Tile angle at clock p: a wave down the run, a hold, then the wave runs back and stands them up.
function angle(i: number, count: number, p: number) {
const lean = (Math.acos(D / GAP) * 180) / Math.PI;
const end = i === count - 1 ? 84 : lean;
const step = 0.42 / count;
const down = span(p, 0.02 + i * step, 0.02 + i * step + 0.09);
const up = span(p, 0.62 + (count - 1 - i) * 0.018, 0.62 + (count - 1 - i) * 0.018 + 0.1);
return end * (down * down) * (1 - up * up * (3 - 2 * up));
}
function hinge(a: number) {
return `rotateX(${-a}deg)`;
}
/* DominoRun: ivory tiles with real pips stand on an S curve in true 3D. A wave knocks them down one by one, then runs back and stands them up again. */
export function DominoRun({ tiles = 18, tile = "#efe6d2", pip = "#1d1b18", speed = 1, label = "Loading", className = "" }: DominoRunProps) {
const reduce = useReducedMotion();
const box = useRef<HTMLDivElement>(null);
const line = useMemo(() => layout(tiles), [tiles]);
useEffect(() => {
if (reduce) return;
const hinges = [...box.current!.querySelectorAll<HTMLElement>("[data-hinge]")];
const shades = [...box.current!.querySelectorAll<HTMLElement>("[data-shade]")];
const seconds = 4.4 / speed;
const start = performance.now() - REST * seconds * 1000;
let raf = 0;
const tick = (now: number) => {
const p = ((now - start) / (seconds * 1000)) % 1;
hinges.forEach((part, i) => {
const a = angle(i, hinges.length, p);
part.style.transform = hinge(a);
shades[i].style.transform = `scaleY(${0.2 + a / 70})`;
});
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [reduce, speed, line]);
const sides: CSSProperties = { backgroundColor: tile, backgroundImage: "linear-gradient(90deg,rgba(0,0,0,0.3),rgba(0,0,0,0.12))" };
return (
<div role="status" aria-label={label} className={`@container w-[30rem] ${className}`}>
<div ref={box} aria-hidden className="relative aspect-[3/2] overflow-hidden text-[1cqw] [perspective:150em]">
<div className="absolute left-[45%] top-[60%] [transform-style:preserve-3d] [transform:rotateX(55deg)_rotateZ(14deg)]">
{line.map((spot, i) => {
const a = angle(i, tiles, REST);
const value = [(i * 5 + 3) % 7, (i * 3 + 1) % 7];
return (
<div
key={i}
className="absolute left-0 top-0 [transform-style:preserve-3d]"
style={{ transform: `translate3d(${spot.x}em,${spot.y}em,0) rotateZ(${(spot.heading * 180) / Math.PI - 90}deg)` }}
>
<span
data-shade
className="absolute block origin-top bg-black/45 blur-[0.3em]"
style={{ left: `${-W / 2}em`, top: 0, width: `${W}em`, height: `${H}em`, transform: `scaleY(${0.2 + a / 70})` }}
/>
<div data-hinge className="absolute left-0 top-0 [transform-style:preserve-3d]" style={{ transform: hinge(a), transformOrigin: `0 ${D / 2}em` }}>
<Face w={W} h={H} at={`translateY(${D / 2}em) rotateX(-90deg)`} style={{ backgroundColor: tile, backgroundImage: face(value[0], value[1], pip) }} />
<Face w={W} h={H} at={`translateY(${-D / 2}em) rotateZ(180deg) rotateX(-90deg)`} style={{ backgroundColor: tile, backgroundImage: face(value[1], value[0], pip) }} />
<Face w={D} h={H} at={`translateX(${W / 2}em) rotateZ(-90deg) rotateX(-90deg)`} style={sides} />
<Face w={D} h={H} at={`translateX(${-W / 2}em) rotateZ(90deg) rotateX(-90deg)`} style={sides} />
<Face w={W} h={D} at={`translateZ(${H}em) translateY(${D / 2}em)`} style={{ backgroundColor: tile, backgroundImage: "linear-gradient(0deg,rgba(255,255,255,0.4),rgba(255,255,255,0))" }} />
</div>
</div>
);
})}
</div>
</div>
</div>
);
}
// One side of a tile: its bottom edge sits on the origin, the transform stands it up.
function Face({ w, h, at, style }: { w: number; h: number; at: string; style: CSSProperties }) {
return (
<span
className="absolute block rounded-[0.3em] origin-bottom"
style={{ left: `${-w / 2}em`, top: `${-h}em`, width: `${w}em`, height: `${h}em`, transform: at, ...style }}
/>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/domino-run.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/domino-run-demo.tsx
import { DominoRun } from "@/components/ui/domino-run"; export default function DominoRunExample() { return ( <div className="@container-size grid h-full place-items-center overflow-hidden bg-[radial-gradient(ellipse_at_50%_55%,#14532d,#07190f_80%)] p-2"> <DominoRun className="w-[min(140cqh,96cqw,480px)]" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| tiles | number | 18 | Number of tiles in the run. |
| tile | string | "#efe6d2" | Tile colour. |
| pip | string | "#1d1b18" | Colour of the pips and the centre line. |
| speed | number | 1 | 1 is the default pace; 2 is twice as fast. |
| label | string | "Loading" | Read out by screen readers (role=status). |
| className | string | "w-[30rem]" | Set the width here. Everything scales with it. |