Particle Field
Canvas-based particles that move, connect with lines, and are attracted by the cursor.
move cursor inside to attract particles
ParticleField.tsx123 lines
"use client";
import { useEffect, useRef } from "react";
interface Particle {
x: number; y: number;
vx: number; vy: number;
r: number;
}
export default function ParticleField() {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d")!;
const W = canvas.offsetWidth;
const H = canvas.offsetHeight;
canvas.width = W;
canvas.height = H;
const COUNT = 70;
const MAX_DIST = 120;
const mouse = { x: -999, y: -999 };
const particles: Particle[] = Array.from({ length: COUNT }, () => ({
x: Math.random() * W,
y: Math.random() * H,
vx: (Math.random() - 0.5) * 0.6,
vy: (Math.random() - 0.5) * 0.6,
r: Math.random() * 1.5 + 0.8,
}));
function onMove(e: MouseEvent) {
const rect = canvas!.getBoundingClientRect();
mouse.x = e.clientX - rect.left;
mouse.y = e.clientY - rect.top;
}
function onLeave() { mouse.x = -999; mouse.y = -999; }
canvas.addEventListener("mousemove", onMove);
canvas.addEventListener("mouseleave", onLeave);
let rafId: number;
function draw() {
ctx.clearRect(0, 0, W, H);
// update
for (const p of particles) {
p.x += p.vx;
p.y += p.vy;
if (p.x < 0 || p.x > W) p.vx *= -1;
if (p.y < 0 || p.y > H) p.vy *= -1;
}
// lines between particles
for (let i = 0; i < particles.length; i++) {
const a = particles[i];
for (let j = i + 1; j < particles.length; j++) {
const b = particles[j];
const dx = a.x - b.x;
const dy = a.y - b.y;
const d = Math.sqrt(dx * dx + dy * dy);
if (d < MAX_DIST) {
ctx.beginPath();
ctx.strokeStyle = `rgba(139,92,246,${(1 - d / MAX_DIST) * 0.45})`;
ctx.lineWidth = 1;
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
}
}
// lines to mouse
const mdx = a.x - mouse.x;
const mdy = a.y - mouse.y;
const md = Math.sqrt(mdx * mdx + mdy * mdy);
if (md < MAX_DIST * 1.6) {
ctx.beginPath();
ctx.strokeStyle = `rgba(167,139,250,${(1 - md / (MAX_DIST * 1.6)) * 0.8})`;
ctx.lineWidth = 1;
ctx.moveTo(a.x, a.y);
ctx.lineTo(mouse.x, mouse.y);
ctx.stroke();
}
}
// dots
for (const p of particles) {
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fillStyle = "rgba(167,139,250,0.9)";
ctx.fill();
}
rafId = requestAnimationFrame(draw);
}
draw();
return () => {
cancelAnimationFrame(rafId);
canvas.removeEventListener("mousemove", onMove);
canvas.removeEventListener("mouseleave", onLeave);
};
}, []);
return (
<div className="relative w-full">
<canvas
ref={canvasRef}
className="w-full h-[420px] rounded-xl bg-zinc-950 cursor-crosshair block"
/>
<p className="absolute bottom-4 left-0 right-0 text-center text-[10px] font-mono text-white/20 pointer-events-none">
move cursor inside to attract particles
</p>
</div>
);
}