"use client"; import { useEffect, useRef, type ReactNode } from "react"; type ParticleStormProps = { children?: ReactNode; className?: string; color?: string; /** Base colour. Also used to fade the trails. */ background?: string; /** Number of particles. */ count?: number; /** 1 is the default pace; 2 is twice as fast. */ speed?: number; }; type Particle = { x: number; y: number }; /* ParticleStorm: particles stream through a slowly shifting wind field and leave swirling trails. */ export function ParticleStorm({ children, className = "", color = "#a78bfa", background = "#06050d", count = 400, speed = 1, }: ParticleStormProps) { const canvasRef = useRef(null); useEffect(() => { const canvas = canvasRef.current; const ctx = canvas?.getContext("2d"); if (!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 parts: Particle[] = []; // Wind direction at a point, in radians. const angle = (x: number, y: number) => Math.sin(x * 0.004 + time * 0.3) * 2 + Math.cos(y * 0.005 - time * 0.2) * 2; const spawn = (): Particle => ({ x: Math.random() * w, y: Math.random() * h }); // Still frame: a short stroke along the wind at each particle. const drawStill = () => { ctx.fillStyle = background; ctx.fillRect(0, 0, w, h); ctx.strokeStyle = color; ctx.globalAlpha = 0.6; ctx.beginPath(); for (const p of parts) { const a = angle(p.x, p.y); ctx.moveTo(p.x, p.y); ctx.lineTo(p.x + Math.cos(a) * 8, p.y + Math.sin(a) * 8); } ctx.stroke(); ctx.globalAlpha = 1; }; const frame = (now: number) => { const dt = last ? Math.min((now - last) / 1000, 0.05) * speed : 0; last = now; time += dt; ctx.globalAlpha = 1 - Math.pow(0.92, dt * 60); ctx.fillStyle = background; ctx.fillRect(0, 0, w, h); ctx.globalAlpha = 0.7; ctx.strokeStyle = color; ctx.beginPath(); parts.forEach((p, i) => { const a = angle(p.x, p.y); const nx = p.x + Math.cos(a) * 60 * dt; const ny = p.y + Math.sin(a) * 60 * dt; ctx.moveTo(p.x, p.y); ctx.lineTo(nx, ny); p.x = nx; p.y = ny; if (nx < 0 || nx > w || ny < 0 || ny > h || Math.random() < 0.002) parts[i] = spawn(); }); ctx.stroke(); ctx.globalAlpha = 1; raf = requestAnimationFrame(frame); }; const start = () => { if (still || raf) return; last = 0; raf = requestAnimationFrame(frame); }; const stop = () => { cancelAnimationFrame(raf); raf = 0; }; 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); parts = Array.from({ length: count }, spawn); drawStill(); }; // Redraw on size change; only animate while on screen. const ro = new ResizeObserver(resize); const io = new IntersectionObserver(([e]) => (e.isIntersecting ? start() : stop())); ro.observe(canvas); io.observe(canvas); return () => { stop(); ro.disconnect(); io.disconnect(); }; }, [color, background, count, speed]); return (
{children}
); }