Particles Background
Original · freecanvas particle background
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/bgx-particles.json"use client";
import { useEffect, useId, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
type Particle = { x: number; y: number; vx: number; vy: number; r: number };
const PALETTES = {
indigo: { label: "Indigo", rgb: [99, 102, 241] as const },
violet: { label: "Violet", rgb: [139, 92, 246] as const },
sky: { label: "Sky", rgb: [56, 189, 248] as const },
emerald: { label: "Emerald", rgb: [16, 185, 129] as const },
amber: { label: "Amber", rgb: [245, 158, 11] as const },
rose: { label: "Rose", rgb: [244, 63, 94] as const },
} as const;
type PaletteKey = keyof typeof PALETTES;
const PALETTE_KEYS = Object.keys(PALETTES) as PaletteKey[];
function makeParticle(w: number, h: number): Particle {
const angle = Math.random() * Math.PI * 2;
const speed = 0.12 + Math.random() * 0.32;
return {
x: Math.random() * w,
y: Math.random() * h,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
r: 1 + Math.random() * 1.7,
};
}
function SwitchRow({
checked,
onChange,
title,
desc,
}: {
checked: boolean;
onChange: (v: boolean) => void;
title: string;
desc: string;
}) {
const labelId = useId();
return (
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<p id={labelId} className="text-sm font-medium text-slate-800 dark:text-slate-100">
{title}
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">{desc}</p>
</div>
<button
type="button"
role="switch"
aria-checked={checked}
aria-labelledby={labelId}
onClick={() => onChange(!checked)}
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950 ${
checked ? "bg-indigo-500" : "bg-slate-300 dark:bg-slate-700"
}`}
>
<span
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow-sm transition-transform duration-200 ${
checked ? "translate-x-[22px]" : "translate-x-0.5"
}`}
/>
</button>
</div>
);
}
export default function BgxParticles() {
const prefersReduced = useReducedMotion();
const reduced = prefersReduced === true;
const [playing, setPlaying] = useState(true);
const [count, setCount] = useState(96);
const [showLinks, setShowLinks] = useState(true);
const [interactive, setInteractive] = useState(true);
const [palette, setPalette] = useState<PaletteKey>("indigo");
const [isDark, setIsDark] = useState(false);
const sectionRef = useRef<HTMLElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const particlesRef = useRef<Particle[]>([]);
const mouseRef = useRef({ x: 0, y: 0, active: false });
const rafRef = useRef<number | null>(null);
const wRef = useRef(0);
const hRef = useRef(0);
const loopingRef = useRef(false);
const countRef = useRef(count);
const showLinksRef = useRef(showLinks);
const interactiveRef = useRef(interactive);
const paletteRef = useRef<PaletteKey>(palette);
const isDarkRef = useRef(isDark);
const advanceRef = useRef<(() => void) | null>(null);
const renderRef = useRef<(() => void) | null>(null);
const swatchRefs = useRef<Array<HTMLButtonElement | null>>([]);
// Detect theme (class / data-theme / system) so canvas colors read on both.
useEffect(() => {
const root = document.documentElement;
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const check = () => {
const attr = root.getAttribute("data-theme");
const dark =
root.classList.contains("dark") ||
attr === "dark" ||
(!root.classList.contains("light") && attr !== "light" && mq.matches);
setIsDark(dark);
};
check();
mq.addEventListener("change", check);
const mo = new MutationObserver(check);
mo.observe(root, { attributes: true, attributeFilter: ["class", "data-theme"] });
return () => {
mq.removeEventListener("change", check);
mo.disconnect();
};
}, []);
// Setup canvas, particles, sizing, and draw routines (once).
useEffect(() => {
const canvas = canvasRef.current;
const section = sectionRef.current;
if (!canvas || !section) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const sizeCanvas = () => {
const rect = section.getBoundingClientRect();
const w = Math.max(1, Math.floor(rect.width));
const h = Math.max(1, Math.floor(rect.height));
wRef.current = w;
hRef.current = h;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.floor(w * dpr);
canvas.height = Math.floor(h * dpr);
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
for (const p of particlesRef.current) {
if (p.x > w) p.x = Math.random() * w;
if (p.y > h) p.y = Math.random() * h;
}
};
const advance = () => {
const w = wRef.current;
const h = hRef.current;
const ps = particlesRef.current;
const mouse = mouseRef.current;
const active = interactiveRef.current && mouse.active;
const R = 150;
const R2 = R * R;
for (let i = 0; i < ps.length; i++) {
const p = ps[i];
p.x += p.vx;
p.y += p.vy;
if (active) {
const dx = p.x - mouse.x;
const dy = p.y - mouse.y;
const d2 = dx * dx + dy * dy;
if (d2 < R2 && d2 > 0.001) {
const d = Math.sqrt(d2);
const f = (1 - d / R) * 2.4;
p.x += (dx / d) * f;
p.y += (dy / d) * f;
}
}
if (p.x < -6) p.x = w + 6;
else if (p.x > w + 6) p.x = -6;
if (p.y < -6) p.y = h + 6;
else if (p.y > h + 6) p.y = -6;
}
};
const render = () => {
const w = wRef.current;
const h = hRef.current;
const ps = particlesRef.current;
const [cr, cg, cb] = PALETTES[paletteRef.current].rgb;
const dark = isDarkRef.current;
const mouse = mouseRef.current;
const active = interactiveRef.current && mouse.active;
ctx.clearRect(0, 0, w, h);
if (showLinksRef.current) {
const maxD = 132;
const maxD2 = maxD * maxD;
ctx.lineWidth = 1;
for (let i = 0; i < ps.length; i++) {
const a = ps[i];
for (let j = i + 1; j < ps.length; j++) {
const b = ps[j];
const dx = a.x - b.x;
const dy = a.y - b.y;
const d2 = dx * dx + dy * dy;
if (d2 < maxD2) {
const alpha = (1 - Math.sqrt(d2) / maxD) * (dark ? 0.45 : 0.55);
ctx.strokeStyle = `rgba(${cr},${cg},${cb},${alpha})`;
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
}
}
}
}
if (active) {
const maxD = 172;
const maxD2 = maxD * maxD;
ctx.lineWidth = 1.1;
for (let i = 0; i < ps.length; i++) {
const p = ps[i];
const dx = p.x - mouse.x;
const dy = p.y - mouse.y;
const d2 = dx * dx + dy * dy;
if (d2 < maxD2) {
const alpha = (1 - Math.sqrt(d2) / maxD) * (dark ? 0.7 : 0.8);
ctx.strokeStyle = `rgba(${cr},${cg},${cb},${alpha})`;
ctx.beginPath();
ctx.moveTo(p.x, p.y);
ctx.lineTo(mouse.x, mouse.y);
ctx.stroke();
}
}
ctx.strokeStyle = `rgba(${cr},${cg},${cb},0.9)`;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(mouse.x, mouse.y, 6, 0, Math.PI * 2);
ctx.stroke();
}
ctx.fillStyle = `rgba(${cr},${cg},${cb},${dark ? 0.95 : 0.82})`;
for (let i = 0; i < ps.length; i++) {
const p = ps[i];
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fill();
}
};
advanceRef.current = advance;
renderRef.current = render;
sizeCanvas();
if (particlesRef.current.length === 0) {
const w = wRef.current;
const h = hRef.current;
particlesRef.current = Array.from({ length: countRef.current }, () => makeParticle(w, h));
}
render();
const ro = new ResizeObserver(() => {
sizeCanvas();
if (!loopingRef.current) renderRef.current?.();
});
ro.observe(section);
return () => {
ro.disconnect();
advanceRef.current = null;
renderRef.current = null;
};
}, []);
// Start / stop the animation loop.
useEffect(() => {
if (!playing || reduced) {
loopingRef.current = false;
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
renderRef.current?.();
return;
}
loopingRef.current = true;
const loop = () => {
advanceRef.current?.();
renderRef.current?.();
rafRef.current = requestAnimationFrame(loop);
};
rafRef.current = requestAnimationFrame(loop);
return () => {
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
loopingRef.current = false;
};
}, [playing, reduced]);
// Reconcile particle count with the slider.
useEffect(() => {
countRef.current = count;
const arr = particlesRef.current;
const w = wRef.current || 1;
const h = hRef.current || 1;
if (count > arr.length) {
for (let i = arr.length; i < count; i++) arr.push(makeParticle(w, h));
} else if (count < arr.length) {
arr.length = count;
}
if (!loopingRef.current) renderRef.current?.();
}, [count]);
// Keep draw-time refs in sync; repaint once when idle.
useEffect(() => {
showLinksRef.current = showLinks;
interactiveRef.current = interactive;
paletteRef.current = palette;
isDarkRef.current = isDark;
if (!loopingRef.current) renderRef.current?.();
}, [showLinks, interactive, palette, isDark]);
const shuffle = () => {
const w = wRef.current || 1;
const h = hRef.current || 1;
particlesRef.current = Array.from({ length: countRef.current }, () => makeParticle(w, h));
if (!loopingRef.current) renderRef.current?.();
};
const accent = PALETTES[palette];
const accentCss = `rgb(${accent.rgb[0]}, ${accent.rgb[1]}, ${accent.rgb[2]})`;
const running = playing && !reduced;
const reveal = (delay: number) =>
reduced
? {}
: {
initial: { opacity: 0, y: 18 },
animate: { opacity: 1, y: 0 },
transition: { duration: 0.6, ease: [0.22, 1, 0.36, 1] as [number, number, number, number], delay },
};
return (
<section
ref={sectionRef}
aria-label="Interactive particle field"
onPointerMove={(e) => {
const el = sectionRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
mouseRef.current.x = e.clientX - rect.left;
mouseRef.current.y = e.clientY - rect.top;
mouseRef.current.active = true;
}}
onPointerLeave={() => {
mouseRef.current.active = false;
}}
className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 via-white to-slate-100 px-6 py-24 text-slate-900 dark:from-slate-950 dark:via-slate-950 dark:to-slate-900 dark:text-slate-100 sm:px-10 lg:px-16"
>
<style>{`
@keyframes bgxp-pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.35; transform: scale(0.65); } }
.bgxp-live-dot { animation: bgxp-pulse 1.8s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.bgxp-live-dot { animation: none; }
}
`}</style>
<canvas
ref={canvasRef}
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-0 h-full w-full"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-0"
style={{
background: `radial-gradient(60% 55% at 78% 12%, rgba(${accent.rgb[0]}, ${accent.rgb[1]}, ${accent.rgb[2]}, 0.13), transparent 70%)`,
}}
/>
<div className="relative z-10 mx-auto grid w-full max-w-6xl items-center gap-12 lg:grid-cols-[1.15fr_1fr]">
<motion.div {...reveal(0)}>
<span className="inline-flex items-center gap-2 rounded-full border border-slate-200/80 bg-white/70 px-3 py-1 text-xs font-medium uppercase tracking-[0.16em] text-slate-600 backdrop-blur dark:border-slate-700/70 dark:bg-slate-900/60 dark:text-slate-300">
<span
className="bgxp-live-dot h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: accentCss }}
/>
Interactive background
</span>
<h2 className="mt-6 text-balance text-4xl font-semibold leading-[1.05] tracking-tight sm:text-5xl lg:text-6xl">
A particle field that
<span className="block" style={{ color: accentCss }}>
bends around your cursor
</span>
</h2>
<p className="mt-6 max-w-lg text-pretty text-base leading-relaxed text-slate-600 dark:text-slate-300 sm:text-lg">
Sixty frames a second of pure 2D canvas — no WebGL, no shaders, nothing beyond
React. Sweep your pointer across the field to push the nodes apart, then tune density,
connections, and palette in real time.
</p>
<p
aria-live="polite"
className="mt-8 inline-flex flex-wrap items-center gap-x-2 gap-y-1 font-mono text-xs text-slate-500 dark:text-slate-400"
>
<span className="text-slate-700 dark:text-slate-200">{count}</span> nodes
<span aria-hidden="true">·</span>
<span className="text-slate-700 dark:text-slate-200">{showLinks ? "linked" : "loose"}</span>
<span aria-hidden="true">·</span>
<span className="text-slate-700 dark:text-slate-200">
{reduced ? "still frame" : running ? "animating" : "paused"}
</span>
</p>
</motion.div>
<motion.div
{...reveal(0.12)}
className="rounded-2xl border border-slate-200/80 bg-white/75 p-6 shadow-xl shadow-slate-900/5 backdrop-blur-md dark:border-slate-700/60 dark:bg-slate-900/60 dark:shadow-black/30 sm:p-7"
>
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">
Canvas controls
</h3>
<button
type="button"
onClick={shuffle}
className="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-2.5 py-1.5 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700 dark:focus-visible:ring-offset-slate-950"
>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M16 3h5v5" />
<path d="M4 20 21 3" />
<path d="M21 16v5h-5" />
<path d="m15 15 6 6" />
<path d="M4 4l5 5" />
</svg>
Shuffle
</button>
</div>
<div className="mt-5 flex items-center gap-3">
<button
type="button"
onClick={() => setPlaying((p) => !p)}
disabled={reduced}
aria-pressed={playing}
aria-label={playing ? "Pause animation" : "Play animation"}
className="inline-flex flex-1 items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-[filter,opacity] hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 dark:focus-visible:ring-offset-slate-950"
style={{ backgroundColor: accentCss, boxShadow: `0 8px 24px -12px ${accentCss}` }}
>
{running ? (
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" aria-hidden="true">
<rect x="6" y="5" width="4" height="14" rx="1" />
<rect x="14" y="5" width="4" height="14" rx="1" />
</svg>
) : (
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" aria-hidden="true">
<path d="M8 5.14v13.72a1 1 0 0 0 1.54.84l10.29-6.86a1 1 0 0 0 0-1.68L9.54 4.3A1 1 0 0 0 8 5.14Z" />
</svg>
)}
{reduced ? "Static" : running ? "Pause" : "Play"}
</button>
</div>
{reduced && (
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
Reduced motion is on — the field renders a single still frame.
</p>
)}
<div className="mt-6">
<label
htmlFor="bgxp-density"
className="flex items-center justify-between text-sm font-medium text-slate-800 dark:text-slate-100"
>
<span>Particle density</span>
<span className="font-mono text-xs text-slate-500 dark:text-slate-400">{count}</span>
</label>
<input
id="bgxp-density"
type="range"
min={24}
max={180}
step={2}
value={count}
onChange={(e) => setCount(Number(e.target.value))}
className="mt-2 w-full cursor-pointer accent-indigo-500"
/>
</div>
<div className="mt-6 space-y-4 border-t border-slate-200/80 pt-5 dark:border-slate-700/60">
<SwitchRow
checked={showLinks}
onChange={setShowLinks}
title="Connection lines"
desc="Draw links between nearby nodes"
/>
<SwitchRow
checked={interactive}
onChange={setInteractive}
title="Cursor repulsion"
desc="Push nodes away from the pointer"
/>
</div>
<div className="mt-6 border-t border-slate-200/80 pt-5 dark:border-slate-700/60">
<p className="mb-3 text-sm font-medium text-slate-800 dark:text-slate-100">Accent palette</p>
<div role="radiogroup" aria-label="Accent palette" className="flex flex-wrap gap-2.5">
{PALETTE_KEYS.map((key, idx) => {
const p = PALETTES[key];
const selected = palette === key;
return (
<button
key={key}
ref={(el) => {
swatchRefs.current[idx] = el;
}}
type="button"
role="radio"
aria-checked={selected}
aria-label={p.label}
title={p.label}
tabIndex={selected ? 0 : -1}
onClick={() => setPalette(key)}
onKeyDown={(e) => {
const keys = PALETTE_KEYS;
let next = -1;
if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (idx + 1) % keys.length;
else if (e.key === "ArrowLeft" || e.key === "ArrowUp")
next = (idx - 1 + keys.length) % keys.length;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = keys.length - 1;
else return;
e.preventDefault();
setPalette(keys[next]);
swatchRefs.current[next]?.focus();
}}
className={`relative flex h-9 w-9 items-center justify-center rounded-full transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-slate-900 focus-visible:ring-offset-white dark:focus-visible:ring-white dark:focus-visible:ring-offset-slate-950 ${
selected
? "ring-2 ring-slate-900 ring-offset-2 ring-offset-white dark:ring-white dark:ring-offset-slate-950"
: ""
}`}
style={{ backgroundColor: `rgb(${p.rgb[0]}, ${p.rgb[1]}, ${p.rgb[2]})` }}
>
{selected && (
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="white" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m5 13 4 4L19 7" />
</svg>
)}
</button>
);
})}
</div>
</div>
</motion.div>
</div>
</section>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Aurora Blobs Background
OriginalAn animated hero section with soft, ever-drifting aurora gradient blobs floating behind staggered, blur-in content.

Retro Perspective Grid
OriginalA launch and waitlist section set over an infinite neon grid that scrolls into a glowing horizon with rising particles.

Conic Gradient Flow
OriginalA frosted hero backed by twin counter-rotating conic gradients, with flowing gradient text and an animated gradient-border stat card.

Spotlight Follow Background
OriginalA dot-grid section where a spring-eased spotlight follows the cursor and reveals a hidden lattice of coloured dots.

Dot Pattern Background
pattern.tsxA reusable animated background for React and Tailwind: dot pattern background.

Flickering Grid Background
grid.tsxA reusable animated background for React and Tailwind: flickering grid background.

Grid Pattern Background
pattern.tsxA reusable animated background for React and Tailwind: grid pattern background.

Retro Grid Background
grid.jsonA reusable animated background for React and Tailwind: retro grid background.

Aurora Background
Originalaurora gradient background

Mesh Gradient Background
Originalanimated mesh gradient background

Grid Background
Originalgrid line background with fade

Dots Background
Originaldotted grid background

