"use client";
import * as React from "react";
import { useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export interface ParticleMorphTextProps {
words?: string[];
/** ms per word when auto-cycling; 0 disables. */
interval?: number;
/** Controlled word index. */
index?: number;
onIndexChange?: (index: number) => void;
/** Sampling gap in px — smaller = more particles. */
gap?: number;
particleSize?: number;
/** Gradient stops across the text. */
colors?: string[];
height?: number;
fontFamily?: string;
fontWeight?: number;
/** Pointer repel radius in px. */
repelRadius?: number;
className?: string;
}
interface P {
x: number;
y: number;
vx: number;
vy: number;
tx: number;
ty: number;
a: number;
ta: number;
c: number;
}
function hexToRgb(hex: string): [number, number, number] {
const h = hex.replace("#", "");
const f = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
const n = parseInt(f.slice(0, 6), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function mulberry32(seed: number) {
return () => {
seed |= 0;
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const BUCKETS = 24;
export function ParticleMorphText({
words = ["Imagine", "Design", "Build", "Ship"],
interval = 3400,
index,
onIndexChange,
gap = 4,
particleSize = 2.2,
colors = ["#8b5cf6", "#ec4899", "#22d3ee"],
height = 240,
fontFamily = "ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif",
fontWeight = 800,
repelRadius = 70,
className,
}: ParticleMorphTextProps) {
const reduce = useReducedMotion() ?? false;
const wrapRef = React.useRef<HTMLDivElement>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const [inner, setInner] = React.useState(0);
const current = index ?? inner;
const word = words[((current % words.length) + words.length) % words.length] ?? "";
const [size, setSize] = React.useState({ w: 0, h: height });
const [active, setActive] = React.useState(true);
const particles = React.useRef<P[]>([]);
const pointer = React.useRef<{ x: number; y: number } | null>(null);
const stepRef = React.useRef(gap);
const cb = React.useRef(onIndexChange);
React.useEffect(() => {
cb.current = onIndexChange;
}, [onIndexChange]);
// Palette buckets.
const palette = React.useMemo(() => {
const stops = colors.map(hexToRgb);
return Array.from({ length: BUCKETS }, (_, i) => {
const t = (i / (BUCKETS - 1)) * (stops.length - 1);
const a = stops[Math.floor(t)];
const b = stops[Math.min(stops.length - 1, Math.floor(t) + 1)];
const f = t - Math.floor(t);
return `rgb(${Math.round(a[0] + (b[0] - a[0]) * f)},${Math.round(a[1] + (b[1] - a[1]) * f)},${Math.round(a[2] + (b[2] - a[2]) * f)})`;
});
}, [colors]);
// Size + visibility.
React.useEffect(() => {
const el = wrapRef.current;
if (!el) return;
const ro = new ResizeObserver(([e]) => setSize({ w: Math.round(e.contentRect.width), h: Math.round(e.contentRect.height) }));
ro.observe(el);
let vis = true;
const upd = () => setActive(vis && !document.hidden);
const io = new IntersectionObserver(([e]) => {
vis = e.isIntersecting;
upd();
});
io.observe(el);
document.addEventListener("visibilitychange", upd);
return () => {
ro.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", upd);
};
}, []);
// Auto-cycle.
React.useEffect(() => {
if (!interval || !active || words.length < 2) return;
const id = setInterval(() => {
const next = (current + 1) % words.length;
if (index === undefined) setInner(next);
cb.current?.(next);
}, interval);
return () => clearInterval(id);
}, [interval, active, words.length, current, index]);
// Retarget particles when the word or size changes.
React.useEffect(() => {
const { w, h } = size;
if (!w || !h) return;
const off = document.createElement("canvas");
off.width = w;
off.height = h;
const g = off.getContext("2d", { willReadFrequently: true });
if (!g) return;
let fs = Math.min(h * 0.72, (w * 0.9) / Math.max(1, word.length * 0.6));
g.font = `${fontWeight} ${fs}px ${fontFamily}`;
const measured = g.measureText(word).width;
if (measured > w * 0.92) fs *= (w * 0.92) / measured;
g.font = `${fontWeight} ${fs}px ${fontFamily}`;
g.textAlign = "center";
g.textBaseline = "middle";
g.fillStyle = "#000";
g.fillText(word, w / 2, h / 2 + fs * 0.04);
const data = g.getImageData(0, 0, w, h).data;
const pts: [number, number][] = [];
// Denser sampling for small type so letters stay legible.
const step = Math.max(2, Math.min(gap, Math.round(fs / 22)));
stepRef.current = step;
for (let y = 0; y < h; y += step) for (let x = 0; x < w; x += step) if (data[(y * w + x) * 4 + 3] > 128) pts.push([x, y]);
// Deterministic shuffle so particles fly to random-looking places.
const rnd = mulberry32(word.length * 97 + pts.length);
for (let i = pts.length - 1; i > 0; i--) {
const j = Math.floor(rnd() * (i + 1));
[pts[i], pts[j]] = [pts[j], pts[i]];
}
let minX = w;
let maxX = 0;
for (const [x] of pts) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
}
const span = Math.max(1, maxX - minX);
const list = particles.current;
const first = list.length === 0;
for (let i = 0; i < pts.length; i++) {
// A little jitter keeps the letters organic instead of a rigid dot matrix.
const tx = pts[i][0] + (rnd() - 0.5) * step * 0.6;
const ty = pts[i][1] + (rnd() - 0.5) * step * 0.6;
const c = Math.round(((tx - minX) / span) * (BUCKETS - 1));
let p = list[i];
if (!p) {
p = { x: Math.random() * w, y: Math.random() * h, vx: 0, vy: 0, tx, ty, a: 0, ta: 1, c };
list.push(p);
}
p.tx = tx;
p.ty = ty;
p.ta = 1;
p.c = c;
if (!first && !reduce) {
p.vx += (Math.random() - 0.5) * 6;
p.vy += (Math.random() - 0.5) * 6;
}
if (reduce) {
p.x = tx;
p.y = ty;
p.a = 1;
}
}
// Extra particles drift away and fade.
for (let i = pts.length; i < list.length; i++) {
const p = list[i];
const ang = Math.random() * Math.PI * 2;
p.tx = w / 2 + Math.cos(ang) * w * 0.6;
p.ty = h / 2 + Math.sin(ang) * h * 0.8;
p.ta = 0;
}
}, [word, size, gap, fontFamily, fontWeight, reduce]);
// Animation loop.
React.useEffect(() => {
const canvas = canvasRef.current;
const { w, h } = size;
if (!canvas || !w || !h || !active) return;
const g = canvas.getContext("2d");
if (!g) return;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = w * dpr;
canvas.height = h * dpr;
g.setTransform(dpr, 0, 0, dpr, 0, 0);
let raf = 0;
let last = performance.now();
const byBucket: P[][] = Array.from({ length: BUCKETS }, () => []);
const loop = (now: number) => {
const f = Math.min(3, (now - last) / 16.67);
last = now;
const list = particles.current;
const ptr = pointer.current;
const R = repelRadius;
const k = 0.075 * f;
const damp = Math.pow(0.8, f);
for (const b of byBucket) b.length = 0;
for (let i = list.length - 1; i >= 0; i--) {
const p = list[i];
let ax = (p.tx - p.x) * k;
let ay = (p.ty - p.y) * k;
if (ptr && !reduce) {
const dx = p.x - ptr.x;
const dy = p.y - ptr.y;
const d2 = dx * dx + dy * dy;
if (d2 < R * R) {
const d = Math.sqrt(d2) || 1;
const force = (1 - d / R) * 7 * f;
ax += (dx / d) * force;
ay += (dy / d) * force;
}
}
p.vx = (p.vx + ax) * damp;
p.vy = (p.vy + ay) * damp;
p.x += p.vx * f;
p.y += p.vy * f;
p.a += (p.ta - p.a) * 0.06 * f;
if (p.ta === 0 && p.a < 0.02) {
list.splice(i, 1);
continue;
}
byBucket[p.c]?.push(p);
}
g.clearRect(0, 0, w, h);
const s = particleSize * Math.min(1, (stepRef.current + 0.5) / Math.max(2, gap));
for (let b = 0; b < BUCKETS; b++) {
const arr = byBucket[b];
if (!arr.length) continue;
g.fillStyle = palette[b];
for (const p of arr) {
g.globalAlpha = p.a;
const speed = Math.abs(p.vx) + Math.abs(p.vy);
const sz = s + Math.min(1.5, speed * 0.08);
g.fillRect(p.x - sz / 2, p.y - sz / 2, sz, sz);
}
}
g.globalAlpha = 1;
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, [size, active, palette, particleSize, repelRadius, reduce, gap]);
const setPtr = (e: React.PointerEvent) => {
const r = e.currentTarget.getBoundingClientRect();
pointer.current = { x: e.clientX - r.left, y: e.clientY - r.top };
};
return (
<div
ref={wrapRef}
onPointerMove={setPtr}
onPointerDown={setPtr}
onPointerLeave={() => (pointer.current = null)}
onPointerUp={(e) => e.pointerType === "touch" && (pointer.current = null)}
className={cn("relative w-full touch-pan-y select-none", className)}
style={{ height }}
>
<canvas ref={canvasRef} aria-hidden className="absolute inset-0 size-full" />
<span className="sr-only" aria-live="polite">
{word}
</span>
</div>
);
}