Canvas particles sampled from locally-drawn text or an SVG path that assemble the shape, roam, and repel from the pointer.
npx shadcn@latest add @paragon/particle-image"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>
);
}