Particles
Drifting, twinkling particle field on a lightweight canvas.
1.4 KB gzippedZero dependenciesReduced-motion safeBase UI & Radix compatible
Particles
Installation
npx shadcn@latest add https://velora.colorlib.com/r/particles.jsonSource
"use client";
import { useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
interface ParticlesProps {
className?: string;
quantity?: number;
/** "r, g, b" string. Defaults to the inherited text color, so it adapts to the theme. */
color?: string;
}
interface Particle {
x: number;
y: number;
vx: number;
vy: number;
size: number;
alpha: number;
twinkle: number;
}
/**
* Drifting, twinkling particle field on a lightweight canvas.
* Renders a single static frame when reduced motion is preferred.
*/
export function Particles({ className, quantity = 60, color }: ParticlesProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const reduced = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
let particles: Particle[] = [];
let raf = 0;
let width = 0;
let height = 0;
// The canvas inherits `color` from its context, so particles match
// the current theme. Computed colors resolve to rgb() in browsers.
const resolveColor = () => {
if (color) return color;
const match = getComputedStyle(canvas).color.match(/\d+(\.\d+)?/g);
return match ? match.slice(0, 3).join(", ") : "255, 255, 255";
};
let rgb = "255, 255, 255";
const resize = () => {
const rect = canvas.getBoundingClientRect();
width = rect.width;
height = rect.height;
canvas.width = width * dpr;
canvas.height = height * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
rgb = resolveColor();
particles = Array.from({ length: quantity }, () => ({
x: Math.random() * width,
y: Math.random() * height,
vx: (Math.random() - 0.5) * 0.15,
vy: (Math.random() - 0.5) * 0.15,
size: Math.random() * 1.6 + 0.4,
alpha: Math.random() * 0.5 + 0.1,
twinkle: Math.random() * 0.02 + 0.005,
}));
};
const draw = () => {
ctx.clearRect(0, 0, width, height);
for (const p of particles) {
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fillStyle = `rgba(${rgb}, ${p.alpha})`;
ctx.fill();
}
};
const tick = () => {
for (const p of particles) {
p.x = (p.x + p.vx + width) % width;
p.y = (p.y + p.vy + height) % height;
p.alpha += p.twinkle;
if (p.alpha > 0.6 || p.alpha < 0.1) p.twinkle *= -1;
}
draw();
raf = requestAnimationFrame(tick);
};
resize();
if (reduced) {
draw();
} else {
raf = requestAnimationFrame(tick);
}
const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(canvas);
// Re-resolve the color when the theme class changes on <html>
const themeObserver = new MutationObserver(() => {
rgb = resolveColor();
if (reduced) draw();
});
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => {
cancelAnimationFrame(raf);
resizeObserver.disconnect();
themeObserver.disconnect();
};
}, [quantity, color]);
return (
<canvas
ref={canvasRef}
aria-hidden
data-slot="particles"
className={cn("pointer-events-none absolute inset-0 size-full", className)}
/>
);
}