// 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 ;
}
window.ParticleField = ParticleField;
})();