"use client";
import * as React from "react";
export type TextScrambleTrigger = "hover" | "view" | "mount";
export type TextScrambleOrder = "forward" | "random" | "center";
export interface TextScrambleProps extends Omit<React.HTMLAttributes<HTMLElement>, "children"> {
/** Final text. Changing it scrambles from the current text to the new one. */
text?: string;
/** Element to render. */
as?: "span" | "p" | "h1" | "h2" | "h3" | "div";
/** Pool of glyphs shown while a character is unresolved. */
characters?: string;
/** Total time until the last character resolves, in ms. */
duration?: number;
/** How often an unresolved glyph changes, in ms. */
interval?: number;
/** What starts the scramble (text changes always scramble). */
trigger?: TextScrambleTrigger;
/** Order in which characters settle. */
order?: TextScrambleOrder;
/** Colour of unresolved glyphs (any CSS colour). */
accent?: string;
}
/* --------------------------- reduced-motion hook --------------------------- */
const REDUCED_QUERY = "(prefers-reduced-motion: reduce)";
function subscribeReducedMotion(onChange: () => void) {
const mq = window.matchMedia(REDUCED_QUERY);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia(REDUCED_QUERY).matches,
() => false,
);
}
/* --------------------------------- engine --------------------------------- */
interface ScrambleOptions {
characters: string;
duration: number;
interval: number;
order: TextScrambleOrder;
}
/**
* Runs one scramble over every cell inside `root`, writing glyphs straight into the DOM
* from requestAnimationFrame (no React state per frame). Returns a cancel function.
*/
function runScramble(root: HTMLElement, { characters, duration, interval, order }: ScrambleOptions) {
const cells = Array.from(root.querySelectorAll<HTMLElement>("[data-ts-cell]"));
const glyphs = cells.map((cell) => cell.querySelector<HTMLElement>("[data-ts-glyph]"));
const pool = Array.from(characters);
const count = cells.length;
if (!count || !pool.length) return () => {};
const randomGlyph = () => pool[Math.floor(Math.random() * pool.length)];
const mid = (count - 1) / 2;
// Every cell starts scrambling at once and settles somewhere in the last 75% of the duration.
const settleAt = cells.map((_, i) => {
let position: number;
if (order === "random") position = Math.random();
else if (order === "center") position = mid === 0 ? 1 : Math.abs(i - mid) / mid;
else position = count === 1 ? 1 : i / (count - 1);
const jitter = order === "random" ? 0 : (Math.random() - 0.5) * 0.08;
return duration * Math.min(1, Math.max(0.25, 0.25 + 0.75 * position + jitter));
});
const reset = (i: number) => {
cells[i].removeAttribute("data-scrambling");
const glyph = glyphs[i];
if (glyph) glyph.textContent = "";
};
cells.forEach((cell, i) => {
cell.setAttribute("data-scrambling", "");
const glyph = glyphs[i];
if (glyph) glyph.textContent = randomGlyph();
});
const start = performance.now();
let lastSwap = start;
let frame = requestAnimationFrame(function tick() {
const now = performance.now();
const elapsed = now - start;
const swap = now - lastSwap >= interval;
if (swap) lastSwap = now;
let pending = 0;
for (let i = 0; i < count; i++) {
if (!cells[i].hasAttribute("data-scrambling")) continue;
if (elapsed >= settleAt[i]) {
reset(i);
continue;
}
pending += 1;
const glyph = glyphs[i];
if (swap && glyph) glyph.textContent = randomGlyph();
}
if (pending) frame = requestAnimationFrame(tick);
});
return () => {
cancelAnimationFrame(frame);
for (let i = 0; i < count; i++) reset(i);
};
}
function stopScramble(cancelRef: React.RefObject<(() => void) | null>) {
cancelRef.current?.();
cancelRef.current = null;
}
/* -------------------------------- component -------------------------------- */
/**
* Text that resolves through a flurry of random glyphs. Each character sits in a cell sized
* by the real final glyph, so the line never changes width while scrambling — in any font.
*/
export function TextScramble({
text = "Signal acquired",
as = "span",
characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#%&*+/<>",
duration = 1000,
interval = 45,
trigger = "hover",
order = "forward",
accent = "#FF7448",
className = "",
style,
onPointerEnter,
onFocus,
...props
}: TextScrambleProps) {
const reduceMotion = usePrefersReducedMotion();
const visualRef = React.useRef<HTMLSpanElement>(null);
const cancelRef = React.useRef<(() => void) | null>(null);
const previousTextRef = React.useRef<string | null>(null);
const Tag = as as React.ElementType;
const start = () => {
const root = visualRef.current;
if (reduceMotion || !root) return;
cancelRef.current?.();
cancelRef.current = runScramble(root, { characters, duration, interval, order });
};
// Effects read the latest options without re-subscribing on every render.
const scramble = React.useEffectEvent(start);
// Mount and in-view triggers.
React.useEffect(() => {
const root = visualRef.current;
if (reduceMotion || !root) return;
if (trigger === "mount") {
scramble();
return () => stopScramble(cancelRef);
}
if (trigger !== "view") return;
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
observer.disconnect();
scramble();
}
},
{ threshold: 0.5 },
);
observer.observe(root);
return () => {
observer.disconnect();
stopScramble(cancelRef);
};
// Re-arm when the timing changes so the new settings can be seen.
}, [trigger, reduceMotion, characters, duration, interval, order]);
// A new `text` always scrambles into place (but not on the first render).
React.useEffect(() => {
const previous = previousTextRef.current;
previousTextRef.current = text;
if (previous === null || previous === text) return;
scramble();
}, [text]);
React.useEffect(() => () => stopScramble(cancelRef), []);
const handlePointerEnter = (event: React.PointerEvent<HTMLElement>) => {
onPointerEnter?.(event);
if (trigger === "hover") start();
};
const handleFocus = (event: React.FocusEvent<HTMLElement>) => {
onFocus?.(event);
if (trigger === "hover") start();
};
// Keep whitespace as real text so lines can still wrap between words.
const tokens = text.split(/(\s+)/).filter(Boolean);
return (
<Tag
className={`relative ${className}`}
style={{ ["--ts-accent" as string]: accent, ...style }}
onPointerEnter={handlePointerEnter}
onFocus={handleFocus}
{...props}
>
<span className="sr-only">{text}</span>
<span ref={visualRef} aria-hidden="true">
{tokens.map((token, t) =>
/^\s+$/.test(token) ? (
" "
) : (
<span key={t} className="inline-block whitespace-nowrap">
{Array.from(token).map((char, c) => (
<span key={c} data-ts-cell="" className="relative inline-block">
{/* The final glyph reserves the width; it is hidden while the cell scrambles. */}
<span className="[[data-scrambling]>&]:invisible">{char}</span>
<span
data-ts-glyph=""
className="pointer-events-none absolute inset-0 flex items-center justify-center text-[var(--ts-accent)]"
/>
</span>
))}
</span>
),
)}
</span>
</Tag>
);
}