Backgrounds

Particle Field

Sparse floating particles driven by a shared requestAnimationFrame loop capped near 30fps, paused when offscreen.

Install

npx shadcn@latest add @paragon/particle-field

particle-field.tsx

"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>
  );
}