Skip to content

Particle Field

Canvas particles that repel from the cursor.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/particle-field.json

Manual

  1. Install the dependencies

    npm install clsx tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/color.ts
    export type Vec3 = [r: number, g: number, b: number];
    
    /**
     * Parses #rgb, #rrggbb or #rrggbbaa (alpha is ignored) into 0–255 channels.
     * Anything else falls back to black, so a typo never crashes a shader.
     */
    export function hexToRgb(hex: string): Vec3 {
      let value = hex.trim().replace(/^#/, "");
      if (value.length === 3 || value.length === 4) value = [...value.slice(0, 3)].map((c) => c + c).join("");
      if (!/^[\da-f]{6}([\da-f]{2})?$/i.test(value)) return [0, 0, 0];
      const n = Number.parseInt(value.slice(0, 6), 16);
      return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
    }
    
    /** Hex color as a normalized vec3 (each channel 0–1) for shader uniforms. */
    export function hexToVec3(hex: string): Vec3 {
      const [r, g, b] = hexToRgb(hex);
      return [r / 255, g / 255, b / 255];
    }
    
    /**
     * Any CSS color the browser understands (hex, rgb(), oklch(), color-mix() or a variable such as
     * `var(--primary)`) as 0–255 channels, computed in the context of `element` so theme variables
     * resolve. Hex takes a fast path; everything else is computed once per call, so cache the result
     * instead of calling this every frame. Browser only; unreadable values fall back to black.
     */
    export function cssColorToRgb(color: string, element: Element): Vec3 {
      const value = color.trim();
      if (/^#[\da-f]{3,8}$/i.test(value)) return hexToRgb(value);
      // Children of a canvas still get computed styles, but a regular parent is the safer host.
      const host = element instanceof HTMLCanvasElement ? (element.parentElement ?? document.body) : element;
      const probe = document.createElement("span");
      probe.style.cssText = "position:absolute;width:0;height:0;overflow:hidden;visibility:hidden";
      probe.style.color = value;
      host.appendChild(probe);
      const computed = getComputedStyle(probe).color;
      probe.remove();
      // A 1×1 canvas turns any computed color space (oklch, color(srgb …)) into sRGB bytes.
      const ctx = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
      if (!ctx) return [0, 0, 0];
      ctx.fillStyle = "#000";
      ctx.fillStyle = computed;
      ctx.fillRect(0, 0, 1, 1);
      const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
      return [r, g, b];
    }
    
    /**
     * Calls `onChange` when the page theme may have changed: a class or data-theme change on <html> or
     * <body> (shadcn's `.dark`, next-themes, a data-theme switch) or a change of the OS color scheme.
     * Resolved colors are cached, so re-resolve them here. Browser only; returns the unsubscribe.
     */
    export function observeTheme(onChange: () => void): () => void {
      const observer = new MutationObserver(onChange);
      const options = { attributes: true, attributeFilter: ["class", "data-theme"] };
      observer.observe(document.documentElement, options);
      if (document.body) observer.observe(document.body, options);
      const scheme = window.matchMedia("(prefers-color-scheme: dark)");
      scheme.addEventListener("change", onChange);
      return () => {
        observer.disconnect();
        scheme.removeEventListener("change", onChange);
      };
    }
    
    /** `cssColorToRgb` as a normalized vec3 (each channel 0–1) for shader uniforms. */
    export function cssColorToVec3(color: string, element: Element): Vec3 {
      const [r, g, b] = cssColorToRgb(color, element);
      return [r / 255, g / 255, b / 255];
    }
    
    const toLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
    const toGamma = (c: number) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
    
    /** 0–255 sRGB to OKLCH: lightness 0–1, chroma, hue in radians. */
    export function rgbToOklch([r, g, b]: Vec3): Vec3 {
      const [lr, lg, lb] = [r, g, b].map((v) => toLinear(v / 255));
      const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
      const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
      const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
      const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
      const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
      const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
      return [L, Math.hypot(A, B), Math.atan2(B, A)];
    }
    
    /** OKLCH back to 0–255 sRGB, clamped into gamut. */
    export function oklchToRgb([L, C, h]: Vec3): Vec3 {
      const A = C * Math.cos(h);
      const B = C * Math.sin(h);
      const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3;
      const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3;
      const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3;
      const rgb = [4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s];
      return rgb.map((v) => Math.round(Math.min(1, Math.max(0, toGamma(Math.min(1, Math.max(0, v))))) * 255)) as Vec3;
    }
    
    /**
     * Mixes two 0–255 colors in OKLCH, taking the short way around the hue circle, so midpoints keep
     * their chroma instead of turning muddy the way an RGB mix does.
     */
    export function mixOklch(a: Vec3, b: Vec3, t: number): Vec3 {
      const [L1, C1, h1] = rgbToOklch(a);
      const [L2, C2, h2] = rgbToOklch(b);
      // A near-gray end has no meaningful hue: borrow the other end's.
      const ha = C1 < 0.02 ? h2 : h1;
      const hb = C2 < 0.02 ? h1 : h2;
      let dh = hb - ha;
      if (dh > Math.PI) dh -= Math.PI * 2;
      if (dh < -Math.PI) dh += Math.PI * 2;
      return oklchToRgb([L1 + (L2 - L1) * t, C1 + (C2 - C1) * t, ha + dh * t]);
    }
    lib/random.ts
    /**
     * Seeded randomness for visuals: dot layouts, characters' moods, print glitches. The same seed
     * gives the same sequence on the server and in the first client frame, so nothing shifts or
     * mismatches on hydration. Not for anything security-related.
     */
    
    /** A 32-bit hash of a string or number, to turn a `seed` prop into a generator state. */
    export function hashSeed(seed: string | number): number {
      const text = typeof seed === "number" ? `n${seed}` : seed;
      // FNV-1a, then a final avalanche so short, similar seeds still diverge.
      let h = 0x811c9dc5;
      for (let i = 0; i < text.length; i++) {
        h ^= text.charCodeAt(i);
        h = Math.imul(h, 0x01000193);
      }
      h ^= h >>> 16;
      h = Math.imul(h, 0x7feb352d);
      h ^= h >>> 15;
      return h >>> 0;
    }
    
    /** mulberry32: a small, fast generator of floats in [0, 1). */
    export function mulberry32(state: number): () => number {
      let a = state >>> 0;
      return () => {
        a = (a + 0x6d2b79f5) >>> 0;
        let t = a;
        t = Math.imul(t ^ (t >>> 15), t | 1);
        t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
        return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
      };
    }
    
    export interface Random {
      /** A float in [0, 1). */
      next(): number;
      /** A float in [min, max). */
      range(min: number, max: number): number;
      /** An integer from min to max, both included. */
      int(min: number, max: number): number;
      /** True with probability `p`. */
      chance(p: number): boolean;
      /** One item of a non-empty list. */
      pick<T>(list: readonly T[]): T;
      /** An independent generator derived from this seed and a salt, e.g. one per word or per coin. */
      fork(salt: string | number): Random;
    }
    
    /** A seeded generator with a few helpers on top of `mulberry32`. */
    export function createRandom(seed: string | number): Random {
      const base = hashSeed(seed);
      const next = mulberry32(base);
      return {
        next,
        range: (min, max) => min + next() * (max - min),
        int: (min, max) => min + Math.floor(next() * (max - min + 1)),
        chance: (p) => next() < p,
        pick: (list) => list[Math.floor(next() * list.length)],
        fork: (salt) => createRandom(`${base}:${salt}`),
      };
    }
  3. Copy the source

    components/ui/particle-field/particle-field.tsx
    "use client";
    
    import { useEffect, useRef, type ComponentProps } from "react";
    import { observeTheme } from "@/lib/color";
    import { createRandom } from "@/lib/random";
    import { cn } from "@/lib/utils";
    
    export interface ParticleFieldProps extends ComponentProps<"div"> {
      /** Particles per 10,000 px² (capped at 180). @default 1.6 */
      density?: number;
      /** Particle color; any CSS color. @default "var(--muted-foreground)" */
      color?: string;
      /** Draw faint lines between nearby particles. @default true */
      links?: boolean;
      /** Distance in px under which particles are linked. @default 110 */
      linkDistance?: number;
      /** Radius in px of the cursor's push. @default 120 */
      repelRadius?: number;
      /** Strength of the cursor's push. @default 1 */
      repelStrength?: number;
      /** Drift speed in px per frame. @default 0.25 */
      speed?: number;
      /** Seed for where particles start and how they drift; the same seed and size give the same field. @default "particles" */
      seed?: string | number;
    }
    
    type RGBA = [number, number, number, number];
    
    function resolveColor(host: HTMLElement, color: string): RGBA {
      const probe = document.createElement("span");
      probe.style.color = color;
      host.appendChild(probe);
      const computed = getComputedStyle(probe).color;
      probe.remove();
      const ctx = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
      if (!ctx) return [0, 0, 0, 1];
      ctx.fillStyle = computed;
      ctx.fillRect(0, 0, 1, 1);
      const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
      return [r, g, b, a / 255];
    }
    
    export function ParticleField({
      density = 1.6,
      color = "var(--muted-foreground)",
      links = true,
      linkDistance = 110,
      repelRadius = 120,
      repelStrength = 1,
      speed = 0.25,
      seed = "particles",
      className,
      children,
      ...props
    }: ParticleFieldProps) {
      const rootRef = useRef<HTMLDivElement>(null);
      const canvasRef = useRef<HTMLCanvasElement>(null);
    
      useEffect(() => {
        const root = rootRef.current;
        const canvas = canvasRef.current;
        const ctx = canvas?.getContext("2d");
        if (!root || !canvas || !ctx) return;
        const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
        let width = 0;
        let height = 0;
        let n = 0;
        // x, y, vx, vy, drift-x, drift-y, radius for each particle.
        let p = new Float32Array(0);
        let rgb: RGBA = [0, 0, 0, 1];
        const pointer = { x: 0, y: 0, inside: false };
        let frame = 0;
        let visible = true;
    
        const scatter = () => {
          const random = createRandom(seed);
          n = Math.min(180, Math.round(((width * height) / 10_000) * density));
          p = new Float32Array(n * 7);
          for (let i = 0; i < n; i++) {
            const angle = random.next() * Math.PI * 2;
            const s = speed * random.range(0.4, 1);
            p.set([random.next() * width, random.next() * height, Math.cos(angle) * s, Math.sin(angle) * s, Math.cos(angle) * s, Math.sin(angle) * s, random.range(1, 2.3)], i * 7);
          }
        };
    
        const draw = () => {
          ctx.clearRect(0, 0, width, height);
          const [r, g, b, a] = rgb;
          if (links) {
            ctx.strokeStyle = `rgb(${r},${g},${b})`;
            ctx.lineWidth = 1;
            const max2 = linkDistance * linkDistance;
            for (let i = 0; i < n; i++) {
              for (let j = i + 1; j < n; j++) {
                const dx = p[i * 7] - p[j * 7];
                const dy = p[i * 7 + 1] - p[j * 7 + 1];
                const d2 = dx * dx + dy * dy;
                if (d2 > max2) continue;
                ctx.globalAlpha = a * 0.35 * (1 - Math.sqrt(d2) / linkDistance);
                ctx.beginPath();
                ctx.moveTo(p[i * 7], p[i * 7 + 1]);
                ctx.lineTo(p[j * 7], p[j * 7 + 1]);
                ctx.stroke();
              }
            }
          }
          ctx.globalAlpha = a * 0.85;
          ctx.fillStyle = `rgb(${r},${g},${b})`;
          ctx.beginPath();
          for (let i = 0; i < n; i++) {
            ctx.moveTo(p[i * 7] + p[i * 7 + 6], p[i * 7 + 1]);
            ctx.arc(p[i * 7], p[i * 7 + 1], p[i * 7 + 6], 0, Math.PI * 2);
          }
          ctx.fill();
          ctx.globalAlpha = 1;
        };
    
        const step = () => {
          for (let i = 0; i < n; i++) {
            const k = i * 7;
            // Velocity relaxes back toward the particle's own gentle drift after a push.
            p[k + 2] += (p[k + 4] - p[k + 2]) * 0.03;
            p[k + 3] += (p[k + 5] - p[k + 3]) * 0.03;
            if (pointer.inside) {
              const dx = p[k] - pointer.x;
              const dy = p[k + 1] - pointer.y;
              const d = Math.hypot(dx, dy);
              if (d < repelRadius && d > 0.01) {
                const force = (1 - d / repelRadius) * repelStrength * 0.9;
                p[k + 2] += (dx / d) * force;
                p[k + 3] += (dy / d) * force;
              }
            }
            p[k] += p[k + 2];
            p[k + 1] += p[k + 3];
            // Wrap around the edges.
            if (p[k] < -10) p[k] = width + 10;
            else if (p[k] > width + 10) p[k] = -10;
            if (p[k + 1] < -10) p[k + 1] = height + 10;
            else if (p[k + 1] > height + 10) p[k + 1] = -10;
          }
        };
    
        const tick = () => {
          step();
          draw();
          frame = requestAnimationFrame(tick);
        };
        const run = () => {
          cancelAnimationFrame(frame);
          if (!reduce && visible) frame = requestAnimationFrame(tick);
        };
    
        const layout = () => {
          width = root.clientWidth;
          height = root.clientHeight;
          const dpr = Math.min(window.devicePixelRatio || 1, 2);
          canvas.width = Math.round(width * dpr);
          canvas.height = Math.round(height * dpr);
          ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
          rgb = resolveColor(root, color);
          scatter();
          draw();
        };
    
        const onMove = (event: PointerEvent) => {
          const rect = root.getBoundingClientRect();
          pointer.x = event.clientX - rect.left;
          pointer.y = event.clientY - rect.top;
          pointer.inside = event.pointerType === "mouse" || event.pointerType === "pen";
        };
        const onLeave = () => (pointer.inside = false);
    
        layout();
        run();
        const resizeObserver = new ResizeObserver(layout);
        resizeObserver.observe(root);
        // Pause the simulation while the field is offscreen.
        const intersection = new IntersectionObserver(([entry]) => {
          visible = entry.isIntersecting;
          run();
        });
        intersection.observe(root);
        // A theme switch changes what the color resolves to.
        const unobserveTheme = observeTheme(() => {
          rgb = resolveColor(root, color);
          draw();
        });
        root.addEventListener("pointermove", onMove);
        root.addEventListener("pointerleave", onLeave);
        return () => {
          cancelAnimationFrame(frame);
          resizeObserver.disconnect();
          intersection.disconnect();
          unobserveTheme();
          root.removeEventListener("pointermove", onMove);
          root.removeEventListener("pointerleave", onLeave);
        };
      }, [density, color, links, linkDistance, repelRadius, repelStrength, speed, seed]);
    
      return (
        <div ref={rootRef} className={cn("relative isolate overflow-hidden", className)} {...props}>
          <canvas ref={canvasRef} aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10 size-full" />
          {children}
        </div>
      );
    }

Usage

import { ParticleField } from "@/components/ui/particle-field/particle-field";

export function Hero() {
  return (
    <ParticleField className="h-96">
      <h1>Everything is connected</h1>
    </ParticleField>
  );
}

API reference

PropTypeDefaultDescription
densitynumber1.6Particles per 10,000 px² (capped at 180).
colorstring"var(--muted-foreground)"Particle color; any CSS color.
linksbooleantrueDraw faint lines between nearby particles.
linkDistancenumber110Distance in px under which particles are linked.
repelRadiusnumber120Radius in px of the cursor's push.
repelStrengthnumber1Strength of the cursor's push.
speednumber0.25Drift speed in px per frame.
seedstring | number"particles"Seed for where particles start and how they drift; the same seed and size give the same field.

Also accepts every native <div> attribute, including ref and className.

  • Particles drift slowly, get pushed away by the cursor, and ease back to their own drift. Nearby particles are linked by faint lines.
  • The simulation pauses while offscreen. Density is per area (capped at 180 particles), so larger fields get more particles.
  • Colors accept any CSS color, including theme variables.
  • Starting positions and drift come from `seed`, so a field of the same size starts the same way on every load (and the reduced-motion still frame matches). Pass a different seed to vary it.

Accessibility

  • The canvas is decorative and aria-hidden.
  • With reduced motion enabled, particles are drawn once and hold still.