Particle Image
Effects & Borders

Particle Image

Canvas particles sampled from locally-drawn text or an SVG path that assemble the shape, roam, and repel from the pointer.

Install

npx shadcn@latest add @paragon/particle-image

particle-image.tsx

"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

/**
 * ParticleImage — canvas particles sampled from a source that is rendered
 * locally (canvas-drawn text, or an inline SVG path — NO remote images). Each
 * bright pixel of the source becomes a particle target; particles fly in and
 * assemble the shape, roam gently, and repel from the pointer, then reassemble
 * once the pointer leaves.
 *
 * Each particle renders as a customizable glyph — a basic shape (dot, square,
 * triangle, ring, plus, cross) or a character/string you supply (ASCII, an
 * emoji, or e.g. "PARAGON" cycled across the field). Character glyphs are
 * rasterized once into a small offscreen atlas and blitted per frame, so no
 * `fillText` runs in the hot loop.
 *
 * Fully self-contained: the source is drawn on an offscreen canvas from `text`
 * (default) or an SVG `path` you provide, so nothing hits the network. Colors
 * inherit the theme via tokens. The animation pauses offscreen, cleans up all
 * rAF/observers/listeners on unmount, and freezes to the assembled shape under
 * `prefers-reduced-motion`.
 */

/** 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;
}

export type ParticleColorMode = "mono" | "primary" | "gradient";

/** Built-in vector glyphs, plus "char" which renders `characters`. */
export type ParticleGlyph =
  | "dot"
  | "square"
  | "triangle"
  | "ring"
  | "plus"
  | "cross"
  | "char";

export interface ParticleImageProps extends React.ComponentProps<"div"> {
  /** Text rendered locally and sampled into particles (ignored if `path` set). */
  text?: string;
  /** An SVG path string (in a 0..100 viewBox) sampled instead of text. */
  path?: string;
  /** Approximate particle count. Sampling density adapts to hit it. */
  particleCount?: number;
  /** Particle radius in px (also the half-extent for char/shape glyphs). */
  size?: number;
  /**
   * Glyph each particle renders as. Basic vector shapes, or "char" to paint
   * the supplied `characters`. Passing a non-empty `characters` string implies
   * "char" unless you override `glyph`.
   */
  glyph?: ParticleGlyph;
  /**
   * Character(s) drawn when `glyph` is "char". A single character (e.g. "+",
   * "◇", "★") repeats across the field; a multi-character string (e.g.
   * "PARAGON", "01") cycles per particle so the field spells it out.
   */
  characters?: string;
  /** How particle color is chosen. */
  colorMode?: ParticleColorMode;
  /** Base color for `mono`, and one end of `gradient`. */
  color?: string;
  /** Pointer repulsion radius in px. */
  repulsion?: number;
  /** Freeze to the assembled shape (no motion). */
  static?: boolean;
}

interface P {
  hx: number; // home x
  hy: number; // home y
  x: number;
  y: number;
  vx: number;
  vy: number;
  c: string;
  ph: number; // wander phase
  g: number; // glyph index (char atlas slot, or rotation seed for shapes)
}

export function ParticleImage({
  text = "PARAGON",
  path,
  particleCount = 900,
  size = 1.6,
  glyph,
  characters = "",
  colorMode = "primary",
  color = "#7df9ff",
  repulsion = 70,
  static: isStatic = false,
  className,
  ...props
}: ParticleImageProps) {
  const reactId = React.useId();
  const wrapRef = React.useRef<HTMLDivElement>(null);
  const canvasRef = React.useRef<HTMLCanvasElement>(null);

  // Resolve the effective glyph: an explicit prop wins; otherwise a non-empty
  // `characters` implies "char"; else the default "dot".
  const chars = React.useMemo(() => Array.from(characters), [characters]);
  const effGlyph: ParticleGlyph =
    glyph ?? (chars.length > 0 ? "char" : "dot");
  const isChar = effGlyph === "char" && chars.length > 0;

  React.useEffect(() => {
    const wrap = wrapRef.current;
    const canvas = canvasRef.current;
    if (!wrap || !canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const rand = mulberry32(
      hashString(reactId + text + (path ?? "") + effGlyph + characters),
    );
    let width = 0;
    let height = 0;
    let dpr = 1;
    let particles: P[] = [];
    let time = 0;

    const pointer = { x: -9999, y: -9999, active: false };

    const readColor = (name: string) => {
      const probe = document.createElement("span");
      probe.style.color = name;
      document.body.appendChild(probe);
      const c = getComputedStyle(probe).color;
      probe.remove();
      return c;
    };

    // --- Glyph atlas (character mode) -------------------------------------
    // Rasterize each unique character once per (color, size) into a tiny
    // offscreen canvas, keyed by "color|char". Blit from these in the draw
    // loop so no fillText runs per frame. Cleared and rebuilt on resize.
    const atlas = new Map<string, HTMLCanvasElement>();
    let atlasFont = 0;
    let atlasPad = 0;
    const glyphKey = (col: string, ch: string) => `${col}|${ch}`;
    const buildGlyph = (col: string, ch: string): HTMLCanvasElement => {
      // Font size derives from `size`: `size` is the particle half-extent, so
      // a ~2.4x diameter keeps char glyphs visually comparable to dots.
      const fontPx = Math.max(6, size * 4.6 * dpr);
      atlasFont = fontPx;
      atlasPad = Math.ceil(fontPx * 0.4);
      const c = document.createElement("canvas");
      const dim = Math.ceil(fontPx + atlasPad * 2);
      c.width = dim;
      c.height = dim;
      const cx = c.getContext("2d");
      if (cx) {
        cx.font = `700 ${fontPx}px ui-sans-serif, system-ui, sans-serif`;
        cx.textAlign = "center";
        cx.textBaseline = "middle";
        cx.fillStyle = col;
        cx.fillText(ch, dim / 2, dim / 2 + fontPx * 0.06);
      }
      return c;
    };
    const glyphFor = (col: string, ch: string): HTMLCanvasElement => {
      const key = glyphKey(col, ch);
      let g = atlas.get(key);
      if (!g) {
        g = buildGlyph(col, ch);
        atlas.set(key, g);
      }
      return g;
    };

    /** Sample the source into home positions. */
    const sample = () => {
      if (width < 4 || height < 4) return;
      const off = document.createElement("canvas");
      const sw = Math.max(1, Math.round(width));
      const sh = Math.max(1, Math.round(height));
      off.width = sw;
      off.height = sh;
      const octx = off.getContext("2d");
      if (!octx) return;
      octx.fillStyle = "#fff";

      if (path) {
        // Path authored in a 0..100 viewBox — fit into the surface.
        const scale = Math.min(sw, sh) / 100;
        const p2d = new Path2D(path);
        octx.save();
        octx.translate((sw - 100 * scale) / 2, (sh - 100 * scale) / 2);
        octx.scale(scale, scale);
        octx.fill(p2d);
        octx.restore();
      } else {
        let fs = Math.min(sh * 0.6, (sw / Math.max(1, text.length)) * 1.7);
        octx.textAlign = "center";
        octx.textBaseline = "middle";
        octx.font = `700 ${fs}px ui-sans-serif, system-ui, sans-serif`;
        // Shrink to fit width.
        while (octx.measureText(text).width > sw * 0.9 && fs > 6) {
          fs -= 2;
          octx.font = `700 ${fs}px ui-sans-serif, system-ui, sans-serif`;
        }
        octx.fillText(text, sw / 2, sh / 2);
      }

      const data = octx.getImageData(0, 0, sw, sh).data;
      // Gather hit pixels, then subsample to ~particleCount. Char glyphs are
      // larger, so thin the field a touch to avoid overlap-mush.
      const hits: Array<[number, number]> = [];
      const step = 2;
      for (let y = 0; y < sh; y += step) {
        for (let x = 0; x < sw; x += step) {
          if (data[(y * sw + x) * 4 + 3] > 128) hits.push([x, y]);
        }
      }
      const target = Math.max(1, Math.round(particleCount * (isChar ? 0.6 : 1)));
      const stride = Math.max(1, Math.floor(hits.length / target));

      const c1 = readColor(color);
      const c2 = readColor("var(--color-primary)");
      const c3 = readColor("var(--color-foreground)");

      // Rebuild the atlas for the new dpr/size so blits stay crisp.
      atlas.clear();

      const next: P[] = [];
      let gi = 0;
      for (let i = 0; i < hits.length; i += stride) {
        const [hx, hy] = hits[i];
        let c: string;
        if (colorMode === "mono") c = c1;
        else if (colorMode === "primary") c = c2;
        else {
          // gradient: blend color -> foreground across x
          c = hx / sw < 0.5 ? c1 : c3;
        }
        // reuse existing particle position if we have one (smooth reshape)
        const prev = next.length < particles.length ? particles[next.length] : null;
        next.push({
          hx,
          hy,
          x: prev ? prev.x : rand() * width,
          y: prev ? prev.y : rand() * height,
          vx: 0,
          vy: 0,
          c,
          ph: rand() * Math.PI * 2,
          // char mode: cycle chars in reading order so multi-char strings
          // spell out. shape mode: stable per-particle rotation seed.
          g: isChar ? gi % chars.length : rand(),
        });
        gi++;
      }
      particles = next;
    };

    /** Draw one vector glyph centered at (x, y) with half-extent r. */
    const drawShape = (x: number, y: number, r: number, rot: number) => {
      switch (effGlyph) {
        case "square": {
          const s = r * 1.7;
          ctx.fillRect(x - s / 2, y - s / 2, s, s);
          break;
        }
        case "triangle": {
          const s = r * 2.1;
          ctx.beginPath();
          ctx.moveTo(x, y - s * 0.62);
          ctx.lineTo(x + s * 0.55, y + s * 0.38);
          ctx.lineTo(x - s * 0.55, y + s * 0.38);
          ctx.closePath();
          ctx.fill();
          break;
        }
        case "ring": {
          const lw = Math.max(0.6, r * 0.55);
          ctx.lineWidth = lw;
          ctx.beginPath();
          ctx.arc(x, y, r * 1.15, 0, Math.PI * 2);
          ctx.stroke();
          break;
        }
        case "plus": {
          const s = r * 2;
          const t = Math.max(0.6, r * 0.6);
          ctx.fillRect(x - t / 2, y - s / 2, t, s);
          ctx.fillRect(x - s / 2, y - t / 2, s, t);
          break;
        }
        case "cross": {
          const s = r * 1.5;
          const t = Math.max(0.6, r * 0.55);
          ctx.save();
          ctx.translate(x, y);
          // slight per-particle tilt off the seed so the field breathes
          ctx.rotate(Math.PI / 4 + (rot - 0.5) * 0.3);
          ctx.fillRect(-t / 2, -s, t, s * 2);
          ctx.fillRect(-s, -t / 2, s * 2, t);
          ctx.restore();
          break;
        }
        default: {
          // dot
          ctx.beginPath();
          ctx.arc(x, y, r, 0, Math.PI * 2);
          ctx.fill();
        }
      }
    };

    const draw = () => {
      ctx.clearRect(0, 0, width, height);
      if (isChar) {
        // Blit pre-rasterized character glyphs. Atlas canvases are in device
        // pixels, but ctx has a dpr transform, so divide back out.
        for (const p of particles) {
          const g = glyphFor(p.c, chars[p.g] ?? chars[0]);
          const w = g.width / dpr;
          const h = g.height / dpr;
          ctx.drawImage(g, p.x - w / 2, p.y - h / 2, w, h);
        }
        return;
      }
      let curColor = "";
      let curStroke = "";
      const stroked = effGlyph === "ring";
      for (const p of particles) {
        if (stroked) {
          if (p.c !== curStroke) {
            ctx.strokeStyle = p.c;
            curStroke = p.c;
          }
        } else if (p.c !== curColor) {
          ctx.fillStyle = p.c;
          curColor = p.c;
        }
        drawShape(p.x, p.y, size, p.g);
      }
    };

    const tick = (dt: number) => {
      const t = Math.min(dt, 40) / 1000;
      time += t;
      const rep2 = repulsion * repulsion;
      for (const p of particles) {
        // wander offset around home
        const wx = Math.sin(time * 0.9 + p.ph) * 1.4;
        const wy = Math.cos(time * 0.8 + p.ph) * 1.4;
        let tx = p.hx + wx;
        let ty = p.hy + wy;

        // pointer repulsion
        if (pointer.active) {
          const dx = p.x - pointer.x;
          const dy = p.y - pointer.y;
          const d2 = dx * dx + dy * dy;
          if (d2 < rep2 && d2 > 0.01) {
            const d = Math.sqrt(d2);
            const force = (1 - d / repulsion) * 26;
            tx += (dx / d) * force;
            ty += (dy / d) * force;
          }
        }

        // spring toward target
        const ax = (tx - p.x) * 0.12;
        const ay = (ty - p.y) * 0.12;
        p.vx = (p.vx + ax) * 0.82;
        p.vy = (p.vy + ay) * 0.82;
        p.x += p.vx;
        p.y += p.vy;
      }
      draw();
    };

    // shared-ish local rAF loop, capped ~50fps
    let rafId: number | null = null;
    let last = 0;
    const TICK = 1000 / 50;
    const loop = (now: number) => {
      rafId = requestAnimationFrame(loop);
      const el = now - last;
      if (el < TICK) return;
      last = now;
      tick(el);
    };

    let running = false;
    let inView = false;
    const reduce = window.matchMedia("(prefers-reduced-motion: reduce)");

    const settle = () => {
      // snap to home for the static/reduced frame
      for (const p of particles) {
        p.x = p.hx;
        p.y = p.hy;
        p.vx = 0;
        p.vy = 0;
      }
      draw();
    };

    const sync = () => {
      const run = inView && !isStatic && !reduce.matches;
      if (run && !running) {
        running = true;
        last = performance.now();
        rafId = requestAnimationFrame(loop);
      } else if (!run && running) {
        running = false;
        if (rafId !== null) cancelAnimationFrame(rafId);
        rafId = null;
        settle();
      } else if (!run) {
        settle();
      }
    };

    const resize = () => {
      const rect = wrap.getBoundingClientRect();
      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);
      ctx.lineJoin = "round";
      ctx.lineCap = "round";
      sample();
      if (!running) settle();
    };

    const fine = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
    const onMove = (e: PointerEvent) => {
      if (!fine) return;
      const rect = wrap.getBoundingClientRect();
      pointer.x = e.clientX - rect.left;
      pointer.y = e.clientY - rect.top;
      pointer.active = true;
    };
    const onLeave = () => {
      pointer.active = false;
      pointer.x = -9999;
      pointer.y = -9999;
    };

    const ro = new ResizeObserver(resize);
    ro.observe(wrap);
    const io = new IntersectionObserver(([e]) => {
      inView = e?.isIntersecting ?? false;
      sync();
    });
    io.observe(wrap);
    reduce.addEventListener("change", sync);
    wrap.addEventListener("pointermove", onMove);
    wrap.addEventListener("pointerleave", onLeave);

    return () => {
      ro.disconnect();
      io.disconnect();
      reduce.removeEventListener("change", sync);
      wrap.removeEventListener("pointermove", onMove);
      wrap.removeEventListener("pointerleave", onLeave);
      atlas.clear();
      if (rafId !== null) cancelAnimationFrame(rafId);
    };
  }, [
    reactId,
    text,
    path,
    particleCount,
    size,
    effGlyph,
    isChar,
    chars,
    characters,
    colorMode,
    color,
    repulsion,
    isStatic,
  ]);

  return (
    <div
      ref={wrapRef}
      data-slot="particle-image"
      className={cn("relative size-full overflow-hidden", className)}
      {...props}
    >
      <canvas ref={canvasRef} className="size-full" aria-hidden />
    </div>
  );
}