"use client";
import * as React from "react";
export type ParticleTextColorMode = "ink" | "accent" | "gradient" | "velocity";
export interface ParticleTextProps extends Omit<React.ComponentPropsWithoutRef<"div">, "children"> {
/** The text to draw. It is exposed once to assistive tech; the particles are decorative. */
text?: string;
/** Element used for the screen-reader copy of the text. */
as?: "h1" | "h2" | "h3" | "p" | "span";
/** Font weight (100–900). The size is fitted to the container automatically. */
fontWeight?: number;
/** Particle diameter, in px. */
particleSize?: number;
/** Sampling density, 0.2–1. Higher means more, closer particles. */
density?: number;
/** Theme ink, the accent, an accent → secondary gradient, or ink that heats up with speed. */
colorMode?: ParticleTextColorMode;
/** Pointer repel radius, in px. */
radius?: number;
/** Accent colour (any CSS colour). */
accent?: string;
/** Second colour of the gradient mode (any CSS colour). */
secondary?: string;
}
type RGB = [number, number, number];
/* ---------------------------------- Colour --------------------------------- */
let probe: CanvasRenderingContext2D | null = null;
/** Resolves any CSS colour (hex, rgb(), oklch()…) to RGB by painting it on a 1×1 canvas. */
function resolveColor(input: string, fallback: RGB): RGB {
probe ??= document.createElement("canvas").getContext("2d", { willReadFrequently: true });
if (!probe) return fallback;
probe.clearRect(0, 0, 1, 1);
probe.fillStyle = "rgba(0, 0, 0, 0)";
probe.fillStyle = input; // invalid colours are ignored → transparent → fallback
probe.fillRect(0, 0, 1, 1);
const [r, g, b, a] = probe.getImageData(0, 0, 1, 1).data;
return a === 0 ? fallback : [r, g, b];
}
const luminance = ([r, g, b]: RGB) => (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
/** On light themes (dark ink), very light colours are pulled toward the ink so they stay visible. */
function legible(color: RGB, ink: RGB): RGB {
if (luminance(ink) > 0.5 || luminance(color) < 0.62) return color;
return color.map((v, i) => Math.round(v + (ink[i] - v) * 0.45)) as RGB;
}
/** Re-reads colours when the theme changes: class/data-theme on <html> or a scoped [data-theme] ancestor. */
function observeTheme(el: Element, onChange: () => void) {
const mo = new MutationObserver(onChange);
const options = { attributes: true, attributeFilter: ["class", "data-theme"] };
mo.observe(document.documentElement, options);
const scoped = el.parentElement?.closest("[data-theme]");
if (scoped && scoped !== document.documentElement) mo.observe(scoped, options);
const mq = window.matchMedia("(prefers-color-scheme: dark)");
mq.addEventListener("change", onChange);
return () => {
mo.disconnect();
mq.removeEventListener("change", onChange);
};
}
/* ----------------------------- Reduced motion ------------------------------ */
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/* -------------------------------- Particles -------------------------------- */
/** Particle state kept across prop changes, so new settings re-form the text instead of restarting it. */
interface Swarm {
count: number;
x: Float32Array;
y: Float32Array;
vx: Float32Array;
vy: Float32Array;
hx: Float32Array;
hy: Float32Array;
}
const STEP_MS = 1000 / 60;
const SPRING = 0.055; // pull toward home per step
const FRICTION = 0.86; // velocity kept per step
const MAX_PARTICLES = 7000;
const HEAT_BUCKETS = 6;
/** Grows (or shrinks) the swarm to `count`, keeping existing particles where they are. */
function resizeSwarm(prev: Swarm | null, count: number, spawn: (i: number) => [number, number]): Swarm {
const next: Swarm = {
count,
x: new Float32Array(count),
y: new Float32Array(count),
vx: new Float32Array(count),
vy: new Float32Array(count),
hx: new Float32Array(count),
hy: new Float32Array(count),
};
const keep = prev ? Math.min(prev.count, count) : 0;
if (prev) {
next.x.set(prev.x.subarray(0, keep));
next.y.set(prev.y.subarray(0, keep));
next.vx.set(prev.vx.subarray(0, keep));
next.vy.set(prev.vy.subarray(0, keep));
}
for (let i = keep; i < count; i++) [next.x[i], next.y[i]] = spawn(i);
return next;
}
/**
* Text drawn as a swarm of particles. The text is rendered once to an offscreen canvas (weight as
* given, size fitted to the container), its pixels are sampled on a grid, and every sample becomes
* a particle's home. Particles spring home, are pushed away by the pointer and drift back; a click
* or tap scatters them. The text itself is exposed once as screen-reader text.
*/
export function ParticleText({
text = "Wancraft",
as: Tag = "p",
fontWeight = 700,
particleSize = 2,
density = 0.6,
colorMode = "velocity",
radius = 100,
accent = "#FF7448",
secondary = "#B6F3D1",
className = "",
...props
}: ParticleTextProps) {
const reduceMotion = usePrefersReducedMotion();
const rootRef = React.useRef<HTMLDivElement>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const swarmRef = React.useRef<Swarm | null>(null);
React.useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
const sampler = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
if (!root || !canvas || !ctx || !sampler) return;
const dot = Math.max(0.5, particleSize);
const reach = Math.max(10, radius);
const fill = Math.min(1, Math.max(0.2, density));
let width = 0;
let height = 0;
let dpr = 1;
let raf = 0;
let last = 0;
let pending = 0;
let moving = true;
let inView = false;
let ink: RGB = [24, 24, 27];
let accentRGB: RGB = [255, 116, 72];
let secondaryRGB: RGB = [182, 243, 209];
let bounds = { left: 0, right: 0 };
const ptr = { clientX: 0, clientY: 0, x: 0, y: 0, inside: false, touch: false };
const readTheme = () => {
ink = resolveColor(getComputedStyle(canvas).color, [24, 24, 27]);
accentRGB = legible(resolveColor(accent, [255, 116, 72]), ink);
secondaryRGB = legible(resolveColor(secondary, [182, 243, 209]), ink);
};
/** Renders the text offscreen and turns its opaque pixels into particle homes. */
const sample = () => {
const label = text.trim() || " ";
const family = getComputedStyle(root).fontFamily || "sans-serif";
const weight = Math.round(Math.min(900, Math.max(100, fontWeight)) / 100) * 100;
sampler.canvas.width = Math.max(1, Math.ceil(width));
sampler.canvas.height = Math.max(1, Math.ceil(height));
sampler.font = `${weight} 100px ${family}`;
const probeWidth = sampler.measureText(label).width || 1;
// Fit: as large as possible within 88% of the width and 62% of the height.
const size = Math.max(10, Math.min(height * 0.62, ((width * 0.88) / probeWidth) * 100));
sampler.font = `${weight} ${size}px ${family}`;
sampler.textAlign = "center";
sampler.textBaseline = "alphabetic";
const m = sampler.measureText(label);
const ascent = m.actualBoundingBoxAscent || size * 0.72;
const descent = m.actualBoundingBoxDescent || 0;
sampler.clearRect(0, 0, width, height);
sampler.fillStyle = "#000";
sampler.fillText(label, width / 2, height / 2 + (ascent - descent) / 2);
bounds = { left: width / 2 - m.width / 2, right: width / 2 + m.width / 2 };
const w = sampler.canvas.width;
const h = sampler.canvas.height;
const alpha = sampler.getImageData(0, 0, w, h).data;
let gap = 9 - fill * 6.5; // px between samples
let homes: number[] = [];
for (let attempt = 0; attempt < 4; attempt++) {
homes = [];
for (let y = gap / 2; y < h; y += gap) {
for (let x = gap / 2; x < w; x += gap) {
if (alpha[(Math.floor(y) * w + Math.floor(x)) * 4 + 3] > 128) homes.push(x, y);
}
}
if (homes.length / 2 <= MAX_PARTICLES) break;
gap *= Math.sqrt(homes.length / 2 / MAX_PARTICLES);
}
const count = homes.length / 2;
const prev = swarmRef.current;
const fresh = !prev || prev.count === 0;
const swarm = resizeSwarm(prev, count, (i) =>
reduceMotion
? [homes[i * 2], homes[i * 2 + 1]]
: fresh
? [Math.random() * width, Math.random() * height] // first build: assemble from a scatter
: [width / 2 + (Math.random() - 0.5) * 40, height / 2 + (Math.random() - 0.5) * 40],
);
for (let i = 0; i < count; i++) {
swarm.hx[i] = homes[i * 2];
swarm.hy[i] = homes[i * 2 + 1];
if (reduceMotion) {
swarm.x[i] = swarm.hx[i];
swarm.y[i] = swarm.hy[i];
swarm.vx[i] = 0;
swarm.vy[i] = 0;
}
}
swarmRef.current = swarm;
};
/** One fixed 60Hz physics step. Returns true while anything is still moving. */
const step = () => {
const s = swarmRef.current;
if (!s) return false;
const r2 = reach * reach;
let active = false;
for (let i = 0; i < s.count; i++) {
let vx = s.vx[i];
let vy = s.vy[i];
if (ptr.inside) {
const dx = s.x[i] - ptr.x;
const dy = s.y[i] - ptr.y;
const d2 = dx * dx + dy * dy;
if (d2 < r2 && d2 > 0.01) {
const d = Math.sqrt(d2);
const f = (1 - d / reach) ** 2 * 3.2;
vx += (dx / d) * f;
vy += (dy / d) * f;
}
}
vx = (vx + (s.hx[i] - s.x[i]) * SPRING) * FRICTION;
vy = (vy + (s.hy[i] - s.y[i]) * SPRING) * FRICTION;
s.x[i] += vx;
s.y[i] += vy;
s.vx[i] = vx;
s.vy[i] = vy;
if (!active && (Math.abs(vx) > 0.01 || Math.abs(vy) > 0.01 || Math.abs(s.hx[i] - s.x[i]) > 0.05 || Math.abs(s.hy[i] - s.y[i]) > 0.05)) {
active = true;
}
}
return active;
};
const rgb = ([r, g, b]: RGB) => `rgb(${r}, ${g}, ${b})`;
const draw = () => {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, width, height);
const s = swarmRef.current;
if (!s) return;
const round = dot >= 2;
const half = dot / 2;
const add = (path: Path2D, i: number) => {
if (round) {
path.moveTo(s.x[i] + half, s.y[i]);
path.arc(s.x[i], s.y[i], half, 0, Math.PI * 2);
} else path.rect(s.x[i] - half, s.y[i] - half, dot, dot);
};
if (colorMode === "velocity") {
// Bucket by speed: ink at rest, heating toward the accent when disturbed.
const paths = Array.from({ length: HEAT_BUCKETS }, () => new Path2D());
for (let i = 0; i < s.count; i++) {
const speed = Math.abs(s.vx[i]) + Math.abs(s.vy[i]);
add(paths[Math.min(HEAT_BUCKETS - 1, Math.floor((speed / 3) * HEAT_BUCKETS))], i);
}
paths.forEach((path, b) => {
const t = b / (HEAT_BUCKETS - 1);
ctx.fillStyle = rgb(ink.map((v, k) => Math.round(v + (accentRGB[k] - v) * t)) as RGB);
ctx.fill(path);
});
return;
}
const path = new Path2D();
for (let i = 0; i < s.count; i++) add(path, i);
if (colorMode === "gradient") {
const grad = ctx.createLinearGradient(bounds.left, 0, bounds.right, 0);
grad.addColorStop(0, rgb(accentRGB));
grad.addColorStop(1, rgb(secondaryRGB));
ctx.fillStyle = grad;
} else ctx.fillStyle = rgb(colorMode === "accent" ? accentRGB : ink);
ctx.fill(path);
};
const frame = (now: number) => {
raf = 0;
const dt = Math.min(64, last ? now - last : STEP_MS);
last = now;
if (ptr.inside) {
const rect = canvas.getBoundingClientRect();
ptr.x = ptr.clientX - rect.left;
ptr.y = ptr.clientY - rect.top;
}
// Fixed 60Hz steps keep the spring identical on 60, 120 or 144Hz displays.
// A short frame may run no step at all; it then keeps the previous "still moving" verdict.
pending = Math.min(pending + dt, STEP_MS * 4);
if (pending >= STEP_MS) moving = false;
while (pending >= STEP_MS) {
moving = step() || moving;
pending -= STEP_MS;
}
draw();
if (moving || ptr.inside) schedule();
else last = 0;
};
const schedule = () => {
if (!raf && inView && !reduceMotion && !document.hidden) raf = requestAnimationFrame(frame);
};
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
last = 0;
};
const rebuild = () => {
sample();
moving = true;
draw();
schedule();
};
const resize = () => {
const rect = canvas.getBoundingClientRect();
if (Math.abs(rect.width - width) < 1 && Math.abs(rect.height - height) < 1 && width > 0) return;
width = rect.width;
height = rect.height;
dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.max(1, Math.round(width * dpr));
canvas.height = Math.max(1, Math.round(height * dpr));
if (width > 0 && height > 0) rebuild();
};
const onThemeChange = () => {
readTheme();
if (!raf) draw();
};
const locate = (e: PointerEvent) => {
const rect = canvas.getBoundingClientRect();
ptr.clientX = e.clientX;
ptr.clientY = e.clientY;
ptr.x = e.clientX - rect.left;
ptr.y = e.clientY - rect.top;
return ptr.x >= 0 && ptr.y >= 0 && ptr.x <= rect.width && ptr.y <= rect.height;
};
const onPointerMove = (e: PointerEvent) => {
if (e.pointerType === "touch" && !ptr.touch) return;
ptr.inside = locate(e);
if (ptr.inside) schedule();
};
const onPointerDown = (e: PointerEvent) => {
const inside = locate(e);
if (!inside) return;
if (e.pointerType === "touch") {
ptr.touch = true;
ptr.inside = true;
}
// Scatter: an outward kick from the press point, stronger close to it.
const s = swarmRef.current;
if (s) {
const blast = reach * 2.2;
for (let i = 0; i < s.count; i++) {
const dx = s.x[i] - ptr.x;
const dy = s.y[i] - ptr.y;
const d = Math.sqrt(dx * dx + dy * dy) || 1;
if (d < blast) {
const f = (1 - d / blast) * 16;
s.vx[i] += (dx / d) * f;
s.vy[i] += (dy / d) * f;
}
}
moving = true;
}
schedule();
};
const onPointerUp = (e: PointerEvent) => {
if (e.pointerType !== "touch") return;
ptr.touch = false;
ptr.inside = false;
};
const onPointerLeave = () => {
ptr.inside = false;
};
const onVisibility = () => (document.hidden ? stop() : schedule());
const listen = (on: boolean) => {
if (on) {
window.addEventListener("pointermove", onPointerMove, { passive: true });
window.addEventListener("pointerdown", onPointerDown, { passive: true });
window.addEventListener("pointerup", onPointerUp, { passive: true });
window.addEventListener("pointercancel", onPointerUp, { passive: true });
document.documentElement.addEventListener("pointerleave", onPointerLeave);
} else {
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("pointerup", onPointerUp);
window.removeEventListener("pointercancel", onPointerUp);
document.documentElement.removeEventListener("pointerleave", onPointerLeave);
}
};
// Pointer tracking lives on the window (only while visible), so the canvas never blocks
// anything layered around it.
const io = new IntersectionObserver(([entry]) => {
inView = entry.isIntersecting;
if (inView) {
if (!reduceMotion) listen(true);
schedule();
} else {
listen(false);
ptr.inside = false;
stop();
}
});
let cancelled = false;
readTheme();
resize();
// Web fonts may still be loading: re-sample once they are ready so the shapes match.
document.fonts?.ready.then(() => {
if (!cancelled && width > 0) rebuild();
});
const ro = new ResizeObserver(resize);
ro.observe(canvas);
io.observe(canvas);
const unobserveTheme = observeTheme(canvas, onThemeChange);
document.addEventListener("visibilitychange", onVisibility);
return () => {
cancelled = true;
stop();
ro.disconnect();
io.disconnect();
unobserveTheme();
listen(false);
document.removeEventListener("visibilitychange", onVisibility);
};
}, [text, fontWeight, particleSize, density, colorMode, radius, accent, secondary, reduceMotion]);
return (
<div ref={rootRef} className={`absolute inset-0 overflow-hidden ${className}`} {...props}>
<Tag className="sr-only">{text}</Tag>
<canvas
ref={canvasRef}
aria-hidden="true"
className="pointer-events-none absolute inset-0 block h-full w-full text-zinc-900 dark:text-zinc-100"
/>
</div>
);
}