"use client"; import { useEffect, useId, useRef, type ElementType, type ReactNode } from "react"; type HeroCanvasProps = { title: string; /** Small line above the title. */ eyebrow?: string; subtitle?: string; /** Buttons or links under the text. */ children?: ReactNode; /** Dot colour. */ color?: string; /** Colour of the dots lifted by the pointer. */ accent?: string; /** 1 is the default pace; 2 is twice as fast. */ speed?: number; /** h1 by default. Use h2 if the page already has an h1. */ headingAs?: ElementType; /** Set the height, background and text colour here. */ className?: string; }; const ROWS = 46; const SPACING = 0.22; const CAMERA = 1.4; /* HeroCanvas: a hero over a sea of dots rolling toward you in perspective. The pointer lifts a swell that travels with it. */ export function HeroCanvas({ title, eyebrow, subtitle, children, color = "#8b7fd6", accent = "#22d3ee", speed = 1, headingAs: Heading = "h1", className = "min-h-[36rem] bg-[#06060e] text-white", }: HeroCanvasProps) { const id = useId(); const box = useRef(null); const canvasRef = useRef(null); useEffect(() => { const section = box.current; const canvas = canvasRef.current; const ctx = canvas?.getContext("2d"); if (!section || !canvas || !ctx) return; const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches; let w = 0; let h = 0; let raf = 0; let last = 0; let time = 0; let onScreen = true; const pointer = { x: 0, y: 0, power: 0, on: false }; const draw = () => { ctx.clearRect(0, 0, w, h); const horizon = h * 0.42; const focal = h * 0.42; const travel = time * 0.35; const flow = travel % SPACING; // Far rows first, so near dots land on top. for (let r = ROWS; r >= 1; r--) { const z = r * SPACING - flow + 0.5; const scale = focal / z; const groundY = horizon + CAMERA * scale; const reach = Math.ceil(w / 2 / (scale * SPACING)) + 1; const depth = Math.min(1, (ROWS - r + 1) / 14); for (let c = -reach; c <= reach; c++) { const worldX = c * SPACING; const swell = Math.sin(worldX * 0.9 + time * 0.6) * 0.1 + Math.sin((z + travel) * 1.3 - time * 0.5) * 0.14; const x = w / 2 + worldX * scale; const dx = (x - pointer.x) / (w * 0.14 + 60); const dy = (groundY - pointer.y) / (h * 0.3 + 40); const lift = pointer.power * Math.exp(-(dx * dx + dy * dy)); const y = groundY - (swell + lift * 0.5) * scale; const size = Math.max(0.9, Math.min(3, scale * 0.016)) * (1 + lift); ctx.globalAlpha = Math.min(1, depth * 0.85 + lift * 0.4); ctx.fillStyle = lift > 0.2 ? accent : color; ctx.fillRect(x - size / 2, y - size / 2, size, size); } } ctx.globalAlpha = 1; }; const tick = (now: number) => { const dt = Math.min(0.05, (now - last) / 1000); last = now; time += dt * speed; pointer.power += ((pointer.on ? 1 : 0) - pointer.power) * Math.min(1, dt * 5); draw(); raf = requestAnimationFrame(tick); }; const start = () => { if (still || raf || !onScreen) return; last = performance.now(); raf = requestAnimationFrame(tick); }; const stop = () => { cancelAnimationFrame(raf); raf = 0; }; const resize = () => { const rect = canvas.getBoundingClientRect(); const dpr = Math.min(2, window.devicePixelRatio || 1); w = rect.width; h = rect.height; canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); draw(); }; const move = (e: PointerEvent) => { if (still || e.pointerType !== "mouse") return; const rect = canvas.getBoundingClientRect(); pointer.x = e.clientX - rect.left; pointer.y = e.clientY - rect.top; pointer.on = true; }; const leave = () => { pointer.on = false; }; const sizes = new ResizeObserver(resize); sizes.observe(canvas); const sight = new IntersectionObserver(([entry]) => { onScreen = entry.isIntersecting; if (onScreen) start(); else stop(); }); sight.observe(canvas); section.addEventListener("pointermove", move); section.addEventListener("pointerleave", leave); start(); return () => { stop(); sizes.disconnect(); sight.disconnect(); section.removeEventListener("pointermove", move); section.removeEventListener("pointerleave", leave); }; }, [color, accent, speed]); return (
{eyebrow &&

{eyebrow}

} {title} {subtitle &&

{subtitle}

} {children &&
{children}
}
); }