Workspace / component / text
Fuzzy Text
paxfx add blocks/component-text-fuzzylocal onlyTier: 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>
);
}