Website Sections
AboutCanvas
A company story in chapters. Each chapter sends a cloud of dots flying into a new word or number, and the dots near the pointer scatter.
Download
- React
- Tailwind CSS v4
- motion
components/ui/about-canvas.tsx
"use client";
import { useEffect, useId, useRef, useState, type CSSProperties, type ElementType, type ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
export type AboutCanvasChapter = {
/** Short name on the button, such as a year. */
label: string;
title: string;
text: string;
/** What the dots spell. Keep it short: a year, a number, a word. */
mark: string;
};
type AboutCanvasProps = {
chapters: AboutCanvasChapter[];
title?: string;
/** Small line above the title. */
eyebrow?: string;
subtitle?: string;
/** Goes under the story: a link or a button. */
children?: ReactNode;
/** Dot colour. */
color?: string;
/** Colour of the dots near the pointer. */
accent?: string;
/** How many dots. */
dots?: number;
/** h2 by default. */
headingAs?: ElementType;
/** Set padding and colours here. */
className?: string;
};
const REACH = 70;
const font = (size: number) => `800 ${size}px system-ui, -apple-system, "Segoe UI", sans-serif`;
/** Where the dots go to spell a word in a w by h box: a shuffled list of spots. */
function spell(mark: string, w: number, h: number, count: number) {
const page = document.createElement("canvas");
page.width = w;
page.height = h;
const ctx = page.getContext("2d", { willReadFrequently: true });
if (!ctx) return [];
ctx.font = font(100);
ctx.font = font(Math.min((100 * w * 0.9) / ctx.measureText(mark).width, h * 0.8));
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(mark, w / 2, h / 2);
const data = ctx.getImageData(0, 0, w, h).data;
const lit: [number, number][] = [];
for (let y = 0; y < h; y += 2) {
for (let x = 0; x < w; x += 2) {
if (data[(y * w + x) * 4 + 3] > 128) lit.push([x, y]);
}
}
for (let i = lit.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[lit[i], lit[j]] = [lit[j], lit[i]];
}
return Array.from({ length: count }, (_, i) => lit[i % lit.length] ?? [w / 2, h / 2]);
}
/* AboutCanvas: a company story in chapters. Each chapter sends a cloud of dots flying into a new word or number, and the dots near the pointer scatter. It stops off screen. */
export function AboutCanvas({
chapters,
title,
eyebrow,
subtitle,
children,
color = "#a78bfa",
accent = "#22d3ee",
dots = 2000,
headingAs: Heading = "h2",
className = "bg-[#0b0a14] px-5 py-16 text-white",
}: AboutCanvasProps) {
const reduce = useReducedMotion();
const id = useId();
const canvasRef = useRef<HTMLCanvasElement>(null);
const [active, setActive] = useState(0);
const chapter = chapters[active];
const word = useRef(chapter.mark);
const retarget = useRef<() => void>(undefined);
useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!canvas || !ctx) return;
const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const parts = Array.from({ length: dots }, () => ({ x: Math.random(), y: Math.random(), vx: 0, vy: 0, tx: 0, ty: 0, wait: 0, hot: 0 }));
let w = 0;
let h = 0;
let frame = 0;
let pointer: { x: number; y: number } | null = null;
const draw = () => {
ctx.clearRect(0, 0, w, h);
for (const p of parts) {
ctx.fillStyle = p.hot > 0.1 ? accent : color;
ctx.globalAlpha = 0.75 + p.hot * 0.25;
ctx.beginPath();
ctx.arc(p.x, p.y, 1.3, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
};
const aim = () => {
if (!w || !h) return;
spell(word.current, w, h, dots).forEach(([x, y], i) => {
const p = parts[i];
p.tx = x;
p.ty = y;
p.wait = Math.random() * 16;
if (still) {
p.x = x;
p.y = y;
}
});
draw();
};
const loop = () => {
for (const p of parts) {
if (p.wait > 0) p.wait -= 1;
else {
p.vx += (p.tx - p.x) * 0.018;
p.vy += (p.ty - p.y) * 0.018;
}
p.hot *= 0.93;
if (pointer) {
const dx = p.x - pointer.x;
const dy = p.y - pointer.y;
const gap = Math.hypot(dx, dy) || 1;
if (gap < REACH) {
const push = (1 - gap / REACH) * 2.4;
p.vx += (dx / gap) * push;
p.vy += (dy / gap) * push;
p.hot = 1;
}
}
p.vx *= 0.84;
p.vy *= 0.84;
p.x += p.vx;
p.y += p.vy;
}
draw();
frame = requestAnimationFrame(loop);
};
const resize = () => {
const scale = Math.min(window.devicePixelRatio || 1, 2);
const first = !w;
w = canvas.clientWidth;
h = canvas.clientHeight;
canvas.width = w * scale;
canvas.height = h * scale;
ctx.setTransform(scale, 0, 0, scale, 0, 0);
if (first) {
for (const p of parts) {
p.x *= w;
p.y *= h;
}
}
aim();
};
const move = (e: PointerEvent) => {
if (e.pointerType !== "mouse") return;
const rect = canvas.getBoundingClientRect();
pointer = { x: e.clientX - rect.left, y: e.clientY - rect.top };
};
const leave = () => {
pointer = null;
};
const watcher = new ResizeObserver(resize);
watcher.observe(canvas);
const onScreen = new IntersectionObserver(([entry]) => {
cancelAnimationFrame(frame);
frame = entry.isIntersecting && !still ? requestAnimationFrame(loop) : 0;
});
onScreen.observe(canvas);
retarget.current = aim;
if (!still) {
canvas.addEventListener("pointermove", move);
canvas.addEventListener("pointerleave", leave);
}
return () => {
cancelAnimationFrame(frame);
retarget.current = undefined;
watcher.disconnect();
onScreen.disconnect();
canvas.removeEventListener("pointermove", move);
canvas.removeEventListener("pointerleave", leave);
};
}, [accent, color, dots]);
const open = (i: number) => {
setActive(i);
word.current = chapters[i].mark;
retarget.current?.();
};
return (
<section aria-labelledby={title ? id : undefined} className={`@container ${className}`} style={{ "--ac-color": color } as CSSProperties}>
<div className="mx-auto grid max-w-6xl items-center gap-6 @3xl:grid-cols-2 @3xl:gap-12">
<div>
{title && (
<header className="mb-6">
{eyebrow && <p className="mb-2 text-xs font-medium tracking-[0.2em] uppercase opacity-70">{eyebrow}</p>}
<Heading id={id} className="text-[clamp(1.5rem,6cqw,2.5rem)] leading-tight font-semibold tracking-tight text-balance">
{title}
</Heading>
{subtitle && <p className="mt-3 text-pretty opacity-70">{subtitle}</p>}
</header>
)}
<div className="flex flex-wrap gap-2">
{chapters.map((item, i) => {
const on = i === active;
return (
<button
key={item.label}
type="button"
aria-pressed={on}
onClick={() => open(i)}
className={`rounded-full border px-3.5 py-1.5 text-sm tabular-nums transition-colors ${on ? "border-[var(--ac-color)] text-[var(--ac-color)]" : "border-current/15 opacity-70 hover:opacity-100"}`}
>
{item.label}
</button>
);
})}
</div>
<div aria-live="polite" className="mt-5 min-h-32">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={active}
initial={{ opacity: 0, y: reduce ? 0 : 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: reduce ? 0 : -10 }}
transition={{ duration: reduce ? 0 : 0.25 }}
>
<h3 className="text-lg font-semibold">{chapter.title}</h3>
<p className="mt-1.5 text-pretty opacity-70">{chapter.text}</p>
</motion.div>
</AnimatePresence>
</div>
{children && <div className="mt-5">{children}</div>}
</div>
<canvas ref={canvasRef} aria-hidden className="aspect-[3/2] w-full" />
</div>
</section>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/about-canvas.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/about-canvas-demo.tsx
import { AboutCanvas } from "@/components/ui/about-canvas"; const CHAPTERS = [ { label: "2019", mark: "2019", title: "Two people, one desk", text: "We started with a kitchen table and a list of things that annoyed us." }, { label: "2022", mark: "40", title: "Forty of us", text: "The team grew across four cities, and the kitchen table stayed in the logo." }, { label: "Now", mark: "NOW", title: "Still small on purpose", text: "We ship every week and answer every message ourselves." }, ]; export default function AboutCanvasExample() { return ( <div className="h-full overflow-y-auto"> <AboutCanvas eyebrow="About us" title="How we got here" chapters={CHAPTERS} headingAs="h3" className="min-h-full bg-[#0b0a14] p-4 text-white" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| chapters | AboutCanvasChapter[] | — | Each has label, title, text and mark. mark is what the dots spell: keep it short. |
| title | string | — | Heading above the chapters. |
| eyebrow | string | — | Small line above the title. |
| subtitle | string | — | Line under the title. |
| children | ReactNode | — | Goes under the story: a link or a button. |
| color | string | "#a78bfa" | Dot colour. |
| accent | string | "#22d3ee" | Colour of the dots near the pointer. |
| dots | number | 2000 | How many dots. |
| headingAs | ElementType | "h2" | Heading tag. |
| className | string | "bg-[#0b0a14] px-5 py-16 text-white" | Set padding and colours here. |