// waiv-particles.jsx — : a cursor-reactive canvas of drifting // card-shards. Mounts absolutely into its parent (which must be position:relative). // Shards parallax by depth, drift slowly, and scatter away from the cursor. (function () { const { useRef, useEffect } = React; function ParticleField({ count = 34, tint = ['#ffd527', '#3f8bff', '#cfe0ff'], opacity = 0.5 }) { const canvasRef = useRef(null); useEffect(() => { const canvas = canvasRef.current; const parent = canvas.parentElement; const ctx = canvas.getContext('2d'); const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches; let W = 0, H = 0, dpr = Math.min(2, window.devicePixelRatio || 1); const parts = []; const rand = (a, b) => a + Math.random() * (b - a); const resize = () => { const r = parent.getBoundingClientRect(); W = r.width; H = r.height; canvas.width = W * dpr; canvas.height = H * dpr; canvas.style.width = W + 'px'; canvas.style.height = H + 'px'; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); }; resize(); for (let i = 0; i < count; i++) { const z = rand(0.32, 1); // depth parts.push({ x: rand(0, W), y: rand(0, H), z, w: rand(16, 30) * z, vx: rand(-0.12, 0.12), vy: rand(-0.16, -0.04), rot: rand(0, Math.PI * 2), vrot: rand(-0.004, 0.004), col: tint[(Math.random() * tint.length) | 0], }); } // pointer in canvas-local coords (-1 when outside) const ptr = { x: -1, y: -1 }; const onMove = (e) => { const r = parent.getBoundingClientRect(); if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) { ptr.x = -1; ptr.y = -1; return; } ptr.x = e.clientX - r.left; ptr.y = e.clientY - r.top; }; window.addEventListener('pointermove', onMove, { passive: true }); const drawShard = (p) => { const w = p.w, h = w / 1.585, r = h * 0.22; ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.rot); ctx.globalAlpha = opacity * (0.18 + 0.55 * p.z); ctx.beginPath(); ctx.moveTo(-w / 2 + r, -h / 2); ctx.arcTo(w / 2, -h / 2, w / 2, h / 2, r); ctx.arcTo(w / 2, h / 2, -w / 2, h / 2, r); ctx.arcTo(-w / 2, h / 2, -w / 2, -h / 2, r); ctx.arcTo(-w / 2, -h / 2, w / 2, -h / 2, r); ctx.closePath(); const g = ctx.createLinearGradient(-w / 2, -h / 2, w / 2, h / 2); g.addColorStop(0, p.col + '55'); g.addColorStop(1, p.col + '08'); ctx.fillStyle = g; ctx.fill(); ctx.lineWidth = 1; ctx.strokeStyle = p.col + '66'; ctx.stroke(); ctx.restore(); }; let raf; const tick = () => { ctx.clearRect(0, 0, W, H); for (const p of parts) { // cursor scatter if (ptr.x >= 0) { const dx = p.x - ptr.x, dy = p.y - ptr.y, d2 = dx * dx + dy * dy, R = 150; if (d2 < R * R) { const d = Math.sqrt(d2) || 1, f = (1 - d / R) * 1.8 * p.z; p.vx += (dx / d) * f; p.vy += (dy / d) * f; } } p.x += p.vx; p.y += p.vy; p.rot += p.vrot; p.vx *= 0.94; p.vy *= 0.94; p.vy -= 0.02 * p.z; // gentle upward drift // wrap const m = 40; if (p.y < -m) { p.y = H + m; p.x = rand(0, W); } if (p.x < -m) p.x = W + m; if (p.x > W + m) p.x = -m; if (p.y > H + m) p.y = -m; drawShard(p); } raf = requestAnimationFrame(tick); }; if (!reduce) raf = requestAnimationFrame(tick); else { for (const p of parts) drawShard(p); } const ro = new ResizeObserver(resize); ro.observe(parent); return () => { cancelAnimationFrame(raf); window.removeEventListener('pointermove', onMove); ro.disconnect(); }; }, []); return