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.
*
* 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>
);
}