"use client"; import { useEffect, useRef, type ReactNode } from "react"; type MagneticGridProps = { children?: ReactNode; className?: string; /** Colour dots light up to near the pointer. */ color?: string; /** Colour of dots at rest. */ dot?: string; background?: string; /** Space between dots in pixels. */ gap?: number; /** Reach of the pointer in pixels. */ radius?: number; }; type Dot = { x: number; y: number; ox: number; oy: number; lit: number }; /* MagneticGrid: a dot grid. Dots near the pointer push away, grow and light up, then settle back. */ export function MagneticGrid({ children, className = "", color = "#a78bfa", dot = "rgba(255,255,255,0.18)", background = "#07060f", gap = 22, radius = 120, }: MagneticGridProps) { const wrapRef = useRef(null); const canvasRef = useRef(null); useEffect(() => { const wrap = wrapRef.current; const canvas = canvasRef.current; const ctx = canvas?.getContext("2d"); if (!wrap || !canvas || !ctx) return; const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches; const pointer = { x: 0, y: 0, inside: false }; let w = 0; let h = 0; let raf = 0; let last = 0; let visible = false; let dots: Dot[] = []; const draw = () => { ctx.clearRect(0, 0, w, h); ctx.fillStyle = dot; for (const d of dots) { ctx.beginPath(); ctx.arc(d.x + d.ox, d.y + d.oy, 1.2, 0, Math.PI * 2); ctx.fill(); } ctx.fillStyle = color; for (const d of dots) { if (d.lit < 0.01) continue; ctx.globalAlpha = d.lit; ctx.beginPath(); ctx.arc(d.x + d.ox, d.y + d.oy, 1.2 + d.lit * 2.2, 0, Math.PI * 2); ctx.fill(); } ctx.globalAlpha = 1; }; const frame = (now: number) => { const dt = last ? Math.min((now - last) / 1000, 0.05) : 0; last = now; const ease = 1 - Math.pow(0.0005, dt); let moving = false; for (const d of dots) { const dx = d.x - pointer.x; const dy = d.y - pointer.y; const dist = Math.hypot(dx, dy) || 1; const f = pointer.inside && dist < radius ? (1 - dist / radius) ** 2 : 0; d.ox += ((dx / dist) * f * gap - d.ox) * ease; d.oy += ((dy / dist) * f * gap - d.oy) * ease; d.lit += (f - d.lit) * ease; if (Math.abs(d.ox) + Math.abs(d.oy) > 0.05 || d.lit > 0.005) moving = true; } draw(); // Stop when the pointer is gone and every dot has settled. raf = pointer.inside || moving ? requestAnimationFrame(frame) : 0; }; const wake = () => { if (still || raf || !visible) return; last = 0; raf = requestAnimationFrame(frame); }; const onMove = (e: PointerEvent) => { const r = canvas.getBoundingClientRect(); pointer.x = e.clientX - r.left; pointer.y = e.clientY - r.top; pointer.inside = true; wake(); }; const onLeave = () => { pointer.inside = false; }; const resize = () => { const dpr = Math.min(window.devicePixelRatio || 1, 2); w = canvas.clientWidth; h = canvas.clientHeight; canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); dots = []; for (let y = gap / 2; y < h; y += gap) { for (let x = gap / 2; x < w; x += gap) dots.push({ x, y, ox: 0, oy: 0, lit: 0 }); } draw(); }; const ro = new ResizeObserver(resize); const io = new IntersectionObserver(([e]) => { visible = e.isIntersecting; if (!visible) { cancelAnimationFrame(raf); raf = 0; } }); ro.observe(canvas); io.observe(canvas); wrap.addEventListener("pointermove", onMove); wrap.addEventListener("pointerleave", onLeave); return () => { cancelAnimationFrame(raf); ro.disconnect(); io.disconnect(); wrap.removeEventListener("pointermove", onMove); wrap.removeEventListener("pointerleave", onLeave); }; }, [color, dot, gap, radius]); return (
{children}
); }