"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(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 (
{title && (
{eyebrow &&

{eyebrow}

} {title} {subtitle &&

{subtitle}

}
)}
{chapters.map((item, i) => { const on = i === active; return ( ); })}

{chapter.title}

{chapter.text}

{children &&
{children}
}
); }