Fazekit

Code

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

More in Text Effects

View all →