Workspace / component / text

Fuzzy Text

Renders type into a canvas and jitters horizontal scanline slices for a broadcast-static edge. Intensity eases up near the pointer.

Workspacecomponenttext

paxfx add blocks/component-text-fuzzylocal only

Tier: medium. Reduced motion: Type renders crisp with no jitter.

"use client";

import { useEffect, useRef } from "react";
import { cn, useReducedMotion } from "@pax-sh/blocks";

export interface FuzzyTextProps {
  text?: string;
  /** Font size in pixels. */
  fontSize?: number;
  fontWeight?: number;
  /** 0 to 1; baseline jitter strength. */
  intensity?: number;
  className?: string;
}

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

/**
 * Renders type into an offscreen canvas once, then jitters horizontal
 * scanline slices onto the visible canvas each frame for a broadcast
 * static edge. Offsets come from a seeded PRNG, intensity eases up as the
 * pointer approaches, and everything is devicePixelRatio-aware (capped at
 * 2) with a ResizeObserver relayout. Reduced motion draws one crisp frame.
 */
export function FuzzyText({
  text = "On air.",
  fontSize = 64,
  fontWeight = 800,
  intensity = 0.5,
  className,
}: FuzzyTextProps) {
  const reduced = useReducedMotion();
  const wrapRef = useRef<HTMLDivElement | null>(null);
  const canvasRef = useRef<HTMLCanvasElement | null>(null);

  useEffect(() => {
    const wrap = wrapRef.current;
    const canvas = canvasRef.current;
    if (!wrap || !canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const off = document.createElement("canvas");
    const offCtx = off.getContext("2d");
    if (!offCtx) return;

    const maxOffset = 3 + intensity * 9;
    const pad = Math.ceil(maxOffset * 2);
    let disposed = false;
    let dpr = 1;

    const layout = () => {
      dpr = Math.min(window.devicePixelRatio || 1, 2);
      const styles = getComputedStyle(wrap);
      const font = `${fontWeight} ${fontSize}px ${styles.fontFamily}`;
      offCtx.font = font;
      const metrics = offCtx.measureText(text);
      const ascent = metrics.actualBoundingBoxAscent || fontSize * 0.8;
      const descent = metrics.actualBoundingBoxDescent || fontSize * 0.25;
      const width = Math.ceil(metrics.width) + pad * 2;
      const height = Math.ceil(ascent + descent) + 8;
      canvas.width = Math.round(width * dpr);
      canvas.height = Math.round(height * dpr);
      canvas.style.width = `${width}px`;
      canvas.style.height = `${height}px`;
      off.width = canvas.width;
      off.height = canvas.height;
      offCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
      offCtx.font = font;
      offCtx.textBaseline = "alphabetic";
      offCtx.fillStyle = styles.color;
      offCtx.fillText(text, pad, ascent + 4);
    };

    const drawStatic = () => {
      ctx.setTransform(1, 0, 0, 1, 0, 0);
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      ctx.drawImage(off, 0, 0);
    };

    layout();
    drawStatic();

    const ro = new ResizeObserver(() => {
      layout();
      drawStatic();
    });
    ro.observe(wrap);
    document.fonts.ready
      .then(() => {
        if (!disposed) {
          layout();
          drawStatic();
        }
      })
      .catch(() => undefined);

    if (reduced) {
      return () => {
        disposed = true;
        ro.disconnect();
      };
    }

    const rand = mulberry32(0x50415846);
    let raf = 0;
    let boost = 0;
    let targetBoost = 0;

    const onPointerMove = (event: PointerEvent) => {
      const rect = canvas.getBoundingClientRect();
      const centerX = rect.left + rect.width / 2;
      const centerY = rect.top + rect.height / 2;
      const distance = Math.hypot(event.clientX - centerX, event.clientY - centerY);
      targetBoost = Math.max(0, 1 - distance / Math.max(rect.width, 1));
    };
    const onPointerLeave = () => {
      targetBoost = 0;
    };
    wrap.addEventListener("pointermove", onPointerMove);
    wrap.addEventListener("pointerleave", onPointerLeave);

    const step = () => {
      raf = requestAnimationFrame(step);
      boost += (targetBoost - boost) * 0.08;
      const strength = (0.45 + 0.55 * boost) * maxOffset * dpr;
      const slice = Math.max(2, Math.round(2 * dpr));
      ctx.setTransform(1, 0, 0, 1, 0, 0);
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      for (let y = 0; y < canvas.height; y += slice) {
        const dx = (rand() * 2 - 1) * strength;
        ctx.drawImage(off, 0, y, canvas.width, slice, dx, y, canvas.width, slice);
      }
    };
    raf = requestAnimationFrame(step);

    return () => {
      disposed = true;
      cancelAnimationFrame(raf);
      ro.disconnect();
      wrap.removeEventListener("pointermove", onPointerMove);
      wrap.removeEventListener("pointerleave", onPointerLeave);
    };
  }, [text, fontSize, fontWeight, intensity, reduced]);

  return (
    <div
      ref={wrapRef}
      className={cn(
        "relative inline-block font-extrabold text-[var(--pax-ink,#12110f)]",
        className,
      )}
      data-pax-asset="component-text-fuzzy"
    >
      <span className="sr-only">{text}</span>
      <canvas ref={canvasRef} aria-hidden="true" className="block" />
    </div>
  );
}