Components
A tiny React canvas component for ambient loading states. Renders a softly fading Conway's Game of Life simulation behind your content — deterministic seeding, reduced-motion support, theme-aware color, and pause-when-hidden. Fully typed props for cell size, density, fade, and step interval.
npx @21st-dev/cli@beta add dqnamo/game-of-life-loaderLoading preview...
"use client";
import { useEffect, useRef, useState } from "react";
import { GameOfLifeLoader } from "@/components/ui/game-of-life-loader";
const W = 520;
const H = 170;
// Exact geometry from the source (520×170 viewBox).
const ORGANIC_Y = [
68.09, 64.13, 42.17, 125.63, 30.31, 79.95, 6.59, 20.21, 0, 73.36, 6.59, 28.99,
];
const SPONSORED_Y = [
170, 170, 170, 170, 170, 170, 170, 170, 117.29, 170, 170, 170,
];
const LOADING_MESSAGES = [
"Mapping follower signals...",
"Preparing chart data...",
"Loading events...",
];
function points(ys: number[]) {
const step = W / (ys.length - 1);
return ys.map((y, index) => [index * step, y] as const);
}
function linePath(ys: number[]) {
return points(ys)
.map(([x, y], index) => `${index === 0 ? "M" : "L"} ${x.toFixed(2)} ${y.toFixed(2)}`)
.join(" ");
}
function areaPath(ys: number[]) {
const line = points(ys)
.map(([x, y]) => `L ${x.toFixed(2)} ${y.toFixed(2)}`)
.join(" ");
return `M ${line.slice(2)} L ${W} ${H} L 0 ${H} Z`;
}
function FollowerChart({ resolved }: { resolved: boolean }) {
return (
<svg
aria-hidden="true"
className="absolute inset-x-0 top-0 bottom-6 h-auto w-full overflow-visible"
preserveAspectRatio="none"
viewBox={`0 0 ${W} ${H}`}
>
<defs>
<linearGradient id="organic-area" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="rgb(59 130 246)" stopOpacity="0.2" />
<stop offset="100%" stopColor="rgb(59 130 246)" stopOpacity="0" />
</linearGradient>
<linearGradient id="sponsored-area" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="rgb(139 92 246)" stopOpacity="0.16" />
<stop offset="100%" stopColor="rgb(139 92 246)" stopOpacity="0" />
</linearGradient>
</defs>
<path
d={areaPath(ORGANIC_Y)}
fill="url(#organic-area)"
style={{ opacity: resolved ? 1 : 0, transition: "opacity 700ms ease-out 180ms" }}
/>
<path
d={areaPath(SPONSORED_Y)}
fill="url(#sponsored-area)"
style={{ opacity: resolved ? 1 : 0, transition: "opacity 700ms ease-out 280ms" }}
/>
<path
d={linePath(ORGANIC_Y)}
fill="none"
pathLength={1}
stroke="rgb(59 130 246)"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
style={{
strokeDasharray: 1,
strokeDashoffset: resolved ? 0 : 1,
opacity: resolved ? 1 : 0,
transition:
"stroke-dashoffset 900ms cubic-bezier(0.22, 1, 0.36, 1), opacity 300ms ease-out",
}}
vectorEffect="non-scaling-stroke"
/>
<path
d={linePath(SPONSORED_Y)}
fill="none"
pathLength={1}
stroke="rgb(139 92 246)"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
style={{
strokeDasharray: 1,
strokeDashoffset: resolved ? 0 : 1,
opacity: resolved ? 1 : 0,
transition:
"stroke-dashoffset 900ms cubic-bezier(0.22, 1, 0.36, 1) 120ms, opacity 300ms ease-out 120ms",
}}
vectorEffect="non-scaling-stroke"
/>
</svg>
);
}
// One rotating loading message that slides + fades in on mount.
function LoadingMessage({ text }: { text: string }) {
const [shown, setShown] = useState(false);
useEffect(() => {
const frame = requestAnimationFrame(() => setShown(true));
return () => cancelAnimationFrame(frame);
}, []);
return (
<span
className="inline-block transition-[translate,opacity] duration-300 ease-out"
style={{
opacity: shown ? 1 : 0,
transform: shown ? "translateY(0)" : "translateY(8px)",
}}
>
{text}
</span>
);
}
export default function GameOfLifeLoaderDemo() {
const [resolved, setResolved] = useState(false);
const [messageIndex, setMessageIndex] = useState(0);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
// Keep the empty wait active, then resolve into the graph once "data is ready".
useEffect(() => {
if (resolved) {
return;
}
timer.current = setTimeout(() => setResolved(true), 2600);
return () => {
if (timer.current) {
clearTimeout(timer.current);
}
};
}, [resolved]);
// Rotate the loading messages while waiting.
useEffect(() => {
if (resolved) {
return;
}
const id = setInterval(
() => setMessageIndex((index) => (index + 1) % LOADING_MESSAGES.length),
1100,
);
return () => clearInterval(id);
}, [resolved]);
function replay() {
if (timer.current) {
clearTimeout(timer.current);
}
setMessageIndex(0);
setResolved(false);
}
return (
<div className="flex min-h-screen w-full items-center justify-center bg-neutral-50 p-8">
<div className="flex w-full max-w-md flex-col gap-3">
<div className="relative h-[22rem] w-full overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-sm">
{/* resolved graph — z-10, below the loader */}
<div className="absolute inset-0 z-10 p-6">
<h3 className="font-medium text-neutral-900 text-sm">
Follower metrics
</h3>
<div className="relative mt-5 h-44">
<div className="absolute inset-x-0 top-0 bottom-6 grid grid-rows-4">
<div className="border-t border-neutral-200" />
<div className="border-t border-neutral-200" />
<div className="border-t border-neutral-200" />
<div className="border-t border-neutral-200" />
</div>
<FollowerChart resolved={resolved} />
<div
className="absolute inset-x-0 bottom-0 flex justify-between font-mono text-[10px] text-neutral-400 uppercase leading-none transition-all duration-500 ease-out"
style={{
opacity: resolved ? 1 : 0,
transform: resolved ? "translateY(0)" : "translateY(8px)",
}}
>
<span>Jan 23</span>
<span>Dec 23</span>
</div>
</div>
<div className="mt-2 divide-y divide-neutral-200">
<div
className="flex items-center justify-between py-2 text-neutral-500 text-sm transition-all duration-500 ease-out"
style={{
opacity: resolved ? 1 : 0,
transform: resolved ? "translateY(0)" : "translateY(8px)",
transitionDelay: "520ms",
}}
>
<div className="flex items-center gap-2">
<span aria-hidden="true" className="h-0.5 w-3 bg-blue-500" />
<span>Organic</span>
</div>
<span className="font-medium text-neutral-900">3,273</span>
</div>
<div
className="flex items-center justify-between py-2 text-neutral-500 text-sm transition-all duration-500 ease-out"
style={{
opacity: resolved ? 1 : 0,
transform: resolved ? "translateY(0)" : "translateY(8px)",
transitionDelay: "610ms",
}}
>
<div className="flex items-center gap-2">
<span aria-hidden="true" className="h-0.5 w-3 bg-violet-500" />
<span>Sponsored</span>
</div>
<span className="font-medium text-neutral-900">120</span>
</div>
</div>
</div>
{/* loading state — z-20, crossfades out to reveal the graph */}
<div
className="absolute inset-0 z-20 overflow-hidden bg-white p-6 transition-all duration-500 ease-out"
style={{
opacity: resolved ? 0 : 1,
transform: resolved ? "translateY(-8px)" : "translateY(0)",
pointerEvents: resolved ? "none" : "auto",
}}
>
<GameOfLifeLoader
cellColor="rgb(100 116 139)"
cellRadius={3}
cellSize={14}
density={0.28}
fadeDuration={920}
maxOpacity={0.22}
stepInterval={620}
/>
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center p-6 text-center">
<p
aria-live="polite"
className="flex h-7 items-center overflow-hidden font-medium text-neutral-500 text-sm leading-5"
>
<LoadingMessage
key={messageIndex}
text={LOADING_MESSAGES[messageIndex]}
/>
</p>
</div>
</div>
</div>
<div className="flex items-center justify-between rounded-xl border border-neutral-200 bg-white px-4 py-3">
<div className="min-w-0">
<p className="font-medium text-neutral-900 text-sm">
Animated loading state
</p>
<p className="text-neutral-500 text-xs">
A live loading state resolves into follower metrics.
</p>
</div>
<button
className="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-neutral-200 bg-white px-2.5 py-1.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50"
onClick={replay}
type="button"
>
<svg
aria-hidden="true"
className="size-3.5"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="M21 12a9 9 0 1 1-2.64-6.36M21 3v6h-6" />
</svg>
Replay
</button>
</div>
</div>
</div>
);
}