"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
type Tag = "h1" | "h2" | "h3" | "p" | "span" | "div" | "a" | "button";
export interface ScrambleTextProps {
text: string;
as?: Tag;
/** What starts the effect. Several triggers can be combined. */
trigger?: Array<"mount" | "hover" | "focus" | "inView">;
/** Total duration in ms until the last character settles. */
duration?: number;
/** How often (ms) unresolved characters pick a new glyph. */
tick?: number;
/** Glyph pool used while scrambling. */
characters?: string;
/** Resolve left-to-right (true) or all characters at random moments (false). */
sequential?: boolean;
/** Class for characters that have not resolved yet. */
scrambleClassName?: string;
className?: string;
onDone?: () => void;
/** Extra props for the rendered element (href, onClick…). */
elementProps?: React.HTMLAttributes<HTMLElement> & { href?: string };
}
const DEFAULT_GLYPHS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#%&*+=<>/\\[]{}";
type Cell = { char: string; done: boolean };
/**
* Text that flickers through random glyphs and settles on its final value.
* Renders the final text on the server and on first paint; glyphs are only generated inside effects,
* so it is hydration safe.
*/
export function ScrambleText({
text,
as = "span",
trigger = ["mount", "hover"],
duration = 900,
tick = 40,
characters = DEFAULT_GLYPHS,
sequential = true,
scrambleClassName = "text-primary",
className,
onDone,
elementProps,
}: ScrambleTextProps) {
const reduce = useReducedMotion();
const ref = React.useRef<HTMLElement>(null);
const [cells, setCells] = React.useState<Cell[] | null>(null);
const raf = React.useRef(0);
const inView = useInView(ref, { once: true, amount: 0.6 });
const onDoneRef = React.useRef(onDone);
React.useEffect(() => {
onDoneRef.current = onDone;
}, [onDone]);
const run = React.useCallback(() => {
if (reduce) return;
cancelAnimationFrame(raf.current);
const chars = Array.from(text);
// Each character gets its settle time up-front.
const settle = chars.map((_, i) =>
sequential ? (duration * 0.35) + (i / Math.max(1, chars.length - 1)) * duration * 0.65 : duration * (0.3 + Math.random() * 0.7),
);
const start = performance.now();
let last = 0;
const loop = (now: number) => {
const t = now - start;
if (now - last >= tick || t >= duration) {
last = now;
const next = chars.map((c, i) => {
if (c === " " || t >= settle[i]) return { char: c, done: true };
return { char: characters[Math.floor(Math.random() * characters.length)], done: false };
});
setCells(next);
}
if (t < duration) raf.current = requestAnimationFrame(loop);
else {
setCells(null);
onDoneRef.current?.();
}
};
raf.current = requestAnimationFrame(loop);
}, [text, duration, tick, characters, sequential, reduce]);
const onMount = trigger.includes("mount");
const onInView = trigger.includes("inView");
React.useEffect(() => {
if (onMount) run();
return () => cancelAnimationFrame(raf.current);
}, [onMount, run]);
React.useEffect(() => {
if (onInView && inView) run();
}, [onInView, inView, run]);
const Comp = as as React.ElementType;
return (
<Comp
ref={ref}
{...elementProps}
onPointerEnter={(e: React.PointerEvent<HTMLElement>) => {
elementProps?.onPointerEnter?.(e);
if (trigger.includes("hover")) run();
}}
onFocus={(e: React.FocusEvent<HTMLElement>) => {
elementProps?.onFocus?.(e);
if (trigger.includes("focus")) run();
}}
className={cn("whitespace-pre-wrap", className)}
>
<span className="sr-only">{text}</span>
{/* The final text always reserves the layout, so scrambling never shifts surrounding content. */}
<span aria-hidden className="relative inline-block">
<span className={cells ? "invisible" : undefined}>{text}</span>
{cells && (
<span className="absolute inset-0">
{cells.map((c, i) => (
<span key={i} className={c.done ? undefined : scrambleClassName}>
{c.char}
</span>
))}
</span>
)}
</span>
</Comp>
);
}