Workspace / component / text
Scramble Text
paxfx add blocks/component-text-scramblelocal onlyTier: low. Reduced motion: Final text renders immediately.
"use client";
import { useEffect, useState } from "react";
import { cn, useInView, useReducedMotion } from "@pax-sh/blocks";
export interface ScrambleTextProps {
text?: string;
/** Heading level or inline element to render. */
as?: "h1" | "h2" | "h3" | "p" | "span";
/** Seconds for the full left-to-right lock-in. */
duration?: number;
/** Glyph pool characters churn through before locking. */
glyphs?: string;
className?: string;
}
const DEFAULT_GLYPHS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#$%&@+=*";
/**
* Characters churn through a glyph pool and lock into place left to right.
* Pool indices derive from character position and a frame tick, so the
* churn is fully deterministic and reads tight instead of noisy. One rAF
* loop drives the whole run and cancels on unmount.
*/
export function ScrambleText({
text = "Signal over noise.",
as: Tag = "h2",
duration = 1.3,
glyphs = DEFAULT_GLYPHS,
className,
}: ScrambleTextProps) {
const reduced = useReducedMotion();
const [ref, inView] = useInView<HTMLSpanElement>({ threshold: 0.4 });
const [display, setDisplay] = useState(text);
useEffect(() => {
if (reduced || !inView) {
setDisplay(text);
return;
}
const chars = Array.from(text);
const span = Math.max(chars.length - 1, 1);
const totalMs = duration * 1000;
let raf = 0;
const start = performance.now();
const step = (now: number) => {
const elapsed = now - start;
if (elapsed >= totalMs) {
setDisplay(text);
return;
}
raf = requestAnimationFrame(step);
const tick = Math.floor(elapsed / 42);
const next = chars
.map((char, i) => {
if (char === " ") return " ";
const lockAt = (i / span) * totalMs * 0.72;
if (elapsed >= lockAt) return char;
return glyphs[(i * 7 + tick * 13 + i * i) % glyphs.length];
})
.join("");
setDisplay(next);
};
raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(raf);
}, [inView, reduced, text, duration, glyphs]);
return (
<Tag
className={cn("m-0 font-semibold tracking-tight", className)}
data-pax-asset="component-text-scramble"
>
<span className="sr-only">{text}</span>
<span ref={ref} aria-hidden="true" className="inline-grid">
<span className="invisible col-start-1 row-start-1 whitespace-pre-wrap">{text}</span>
<span className="col-start-1 row-start-1 whitespace-pre-wrap">{display}</span>
</span>
</Tag>
);
}