Sparse floating particles driven by a shared requestAnimationFrame loop capped near 30fps, paused when offscreen.
npx shadcn@latest add @paragon/particle-field"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/* ------------------------------------------------------------------------
* Shared ticker — one module-level requestAnimationFrame loop drives every
* ParticleField on the page. Instances subscribe/unsubscribe; the loop only
* exists while at least one subscriber is live, and it dispatches at most
* ~30 ticks per second regardless of display refresh rate.
* ---------------------------------------------------------------------- */
type TickFn = (dt: number) => void;
const TICK_MS = 1000 / 30;
const subscribers = new Set<TickFn>();
let rafId: number | null = null;
let lastTick = 0;
function frame(now: number) {
rafId = requestAnimationFrame(frame);
const elapsed = now - lastTick;
if (elapsed < TICK_MS) return;
lastTick = now - (elapsed % TICK_MS);
// Clamp dt so a background-tab return doesn't teleport particles.
const dt = Math.min(elapsed, 100);
for (const tick of subscribers) tick(dt);
}
function subscribeToTicker(tick: TickFn): () => void {
subscribers.add(tick);
if (rafId === null) {
lastTick = performance.now();
rafId = requestAnimationFrame(frame);
}
return () => {
subscribers.delete(tick);
if (subscribers.size === 0 && rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
};
}
/* ----------------------------------------------------------- deterministic */
/** Small deterministic PRNG so layouts are stable across renders/hydration. */
function mulberry32(seed: number) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function hashString(input: string): number {
let h = 0;
for (let i = 0; i < input.length; i++) {
h = (Math.imul(31, h) + input.charCodeAt(i)) | 0;
}
return h;
}
interface Particle {
/** Normalized position, 0–1 (slight overshoot allowed for edge wrap). */
x: number;
y: number;
/** Drift velocity in px/s. */
vx: number;
vy: number;
/** Radius in px. */
r: number;
/** Base opacity. */
alpha: number;
/** Twinkle phase offset (rad) and angular speed (rad/s). */
phase: number;
twinkle: number;
}
function createParticles(count: number, seed: number): Particle[] {
const rand = mulberry32(seed);
return Array.from({ length: Math.max(0, count) }, () => ({
x: rand(),
y: rand(),
vx: (rand() - 0.5) * 6,
vy: -(2 + rand() * 6),
r: 0.6 + rand() * 1.2,
alpha: 0.15 + rand() * 0.35,
phase: rand() * Math.PI * 2,
twinkle: 0.4 + rand() * 0.8,
}));
}
export interface ParticleFieldProps extends React.ComponentProps<"div"> {
/** Number of particles. Keep it sparse — this is atmosphere, not confetti. */
count?: number;
/** Drift speed multiplier. 1 is a slow upward float. */
speed?: number;
/** Seed for deterministic particle placement. Defaults to a per-instance id. */
seed?: number;
/** Fade the field toward the edges with a radial mask. */
fade?: boolean;
/** Render the seeded dots without any motion. */
static?: boolean;
}
/**
* Sparse floating particles drifting slowly upward, with a gentle twinkle.
*
* Engine: every instance on the page shares one module-level
* requestAnimationFrame loop capped near 30fps — never one loop per particle
* or per instance. Positions are seeded from a deterministic PRNG (props or
* `useId`), never Math.random at render. An IntersectionObserver detaches the
* instance from the ticker while offscreen, and `prefers-reduced-motion`
* (or `static`) shows the seeded dots frozen in place. Particles inherit
* `currentColor` — retint via a text color class. Absolutely positioned —
* parent needs position: relative.
*/
export function ParticleField({
count = 32,
speed = 1,
seed,
fade = true,
static: isStatic = false,
className,
style,
...props
}: ParticleFieldProps) {
const reactId = React.useId();
const resolvedSeed = seed ?? hashString(reactId);
const wrapperRef = React.useRef<HTMLDivElement>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
React.useEffect(() => {
const wrapper = wrapperRef.current;
const canvas = canvasRef.current;
if (!wrapper || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const particles = createParticles(count, resolvedSeed);
let width = 0;
let height = 0;
let time = 0;
const draw = () => {
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = getComputedStyle(canvas).color;
for (const p of particles) {
ctx.globalAlpha =
p.alpha * (0.65 + 0.35 * Math.sin(p.phase + time * p.twinkle));
ctx.beginPath();
ctx.arc(p.x * width, p.y * height, p.r, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
};
const tick = (dt: number) => {
const s = (dt / 1000) * speed;
time += dt / 1000;
const w = Math.max(width, 1);
const h = Math.max(height, 1);
for (const p of particles) {
p.x += (p.vx * s) / w;
p.y += (p.vy * s) / h;
// Wrap with a small margin so dots never pop at the edges.
if (p.x < -0.02) p.x += 1.04;
else if (p.x > 1.02) p.x -= 1.04;
if (p.y < -0.02) p.y += 1.04;
else if (p.y > 1.02) p.y -= 1.04;
}
draw();
};
let unsubscribe: (() => void) | null = null;
let inView = false;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
const sync = () => {
const shouldRun = inView && !isStatic && !reduceMotion.matches;
if (shouldRun && !unsubscribe) {
unsubscribe = subscribeToTicker(tick);
} else if (!shouldRun && unsubscribe) {
unsubscribe();
unsubscribe = null;
draw(); // leave a clean static frame behind
}
};
const resize = () => {
const rect = wrapper.getBoundingClientRect();
const dpr = Math.min(window.devicePixelRatio || 1, 2);
width = rect.width;
height = rect.height;
canvas.width = Math.max(1, Math.round(width * dpr));
canvas.height = Math.max(1, Math.round(height * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
draw(); // keep the paused/static frame crisp after layout changes
};
const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(wrapper);
const intersectionObserver = new IntersectionObserver(([entry]) => {
inView = entry?.isIntersecting ?? false;
sync();
});
intersectionObserver.observe(wrapper);
reduceMotion.addEventListener("change", sync);
return () => {
resizeObserver.disconnect();
intersectionObserver.disconnect();
reduceMotion.removeEventListener("change", sync);
unsubscribe?.();
};
}, [count, speed, resolvedSeed, isStatic]);
return (
<div
ref={wrapperRef}
aria-hidden
data-slot="particle-field"
className={cn(
"pointer-events-none absolute inset-0 overflow-hidden text-foreground",
className,
)}
style={{
...(fade && {
maskImage:
"radial-gradient(ellipse at 50% 50%, black 55%, transparent 95%)",
WebkitMaskImage:
"radial-gradient(ellipse at 50% 50%, black 55%, transparent 95%)",
}),
...style,
}}
{...props}
>
<canvas ref={canvasRef} className="size-full" />
</div>
);
}