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.
 *
 * 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";

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. */
  size?: number;
  /** 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
}

export function ParticleImage({
  text = "PARAGON",
  path,
  particleCount = 900,
  size = 1.6,
  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);

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

    /** 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.
      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, particleCount);
      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)");

      const next: P[] = [];
      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,
        });
      }
      particles = next;
    };

    const draw = () => {
      ctx.clearRect(0, 0, width, height);
      for (const p of particles) {
        ctx.fillStyle = p.c;
        ctx.beginPath();
        ctx.arc(p.x, p.y, size, 0, Math.PI * 2);
        ctx.fill();
      }
    };

    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);
      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);
      if (rafId !== null) cancelAnimationFrame(rafId);
    };
  }, [
    reactId,
    text,
    path,
    particleCount,
    size,
    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>
  );
}