A scramble-decode effect where random glyphs resolve left-to-right into the final string.
npx shadcn@latest add @paragon/text-shuffle"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
const DEFAULT_GLYPHS =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
/** Deterministic 32-bit PRNG (mulberry32) — identical on server and client. */
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;
};
}
/** FNV-1a hash — turns useId + text into a stable numeric seed. */
function hashString(str: string) {
let h = 2166136261;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
export interface TextShuffleProps
extends Omit<React.ComponentProps<"span">, "children"> {
/** The final string the scramble resolves into. */
children: string;
/** Milliseconds before decoding starts. */
delay?: number;
/** Milliseconds between each character locking in, left to right. */
stagger?: number;
/** Milliseconds between glyph swaps on still-scrambled characters. */
speed?: number;
/** Glyph pool the scramble draws from. */
glyphs?: string;
/** Seed for the deterministic scramble. Defaults to a hash of useId + text. */
seed?: number;
/** Decode once and stay resolved, or rescramble and replay on re-entry. */
once?: boolean;
/** Render the final text plainly with no motion. */
static?: boolean;
}
/**
* A scramble-decode effect: random glyphs churn in place and resolve
* left-to-right into the final string when scrolled into view.
*
* Each character sits in a cell sized by an invisible copy of its final
* glyph, so the line never reflows mid-decode — in any font, monospace or
* not. The scramble is seeded (useId or `seed` prop), never Math.random
* during render, so server and client paint the same first frame.
*
* Inline element: wrap it in your own heading or paragraph. The full string
* stays available to screen readers; the churning glyphs are decorative.
* Reduced motion (or `static`) renders the final text immediately.
*/
export function TextShuffle({
children,
delay = 0,
stagger = 30,
speed = 40,
glyphs = DEFAULT_GLYPHS,
seed,
once = true,
static: isStatic = false,
className,
...props
}: TextShuffleProps) {
const id = React.useId();
const ref = React.useRef<HTMLSpanElement>(null);
const inView = useInView(ref, { once, amount: 0.4 });
const reducedMotion = useReducedMotion() ?? false;
const chars = React.useMemo(() => Array.from(children), [children]);
const seedBase = seed ?? hashString(id + children);
// Deterministic first frame: the same seed produces the same scramble on
// the server and the client, so hydration never mismatches.
const initialScramble = React.useMemo(() => {
const random = mulberry32(seedBase);
return chars.map((ch) =>
/\s/.test(ch) ? ch : glyphs[Math.floor(random() * glyphs.length)],
);
}, [chars, glyphs, seedBase]);
const [state, setState] = React.useState<{
display: string[];
locked: number;
}>({ display: initialScramble, locked: 0 });
// Reset during render when the text/seed/glyphs change (memo identity).
const [prevScramble, setPrevScramble] = React.useState(initialScramble);
if (prevScramble !== initialScramble) {
setPrevScramble(initialScramble);
setState({ display: initialScramble, locked: 0 });
}
React.useEffect(() => {
if (isStatic) return;
if (!inView) {
// once={false}: rescramble while offscreen so re-entry replays.
if (!once) setState({ display: initialScramble, locked: 0 });
return;
}
if (reducedMotion) {
setState({ display: chars, locked: chars.length });
return;
}
// Client-only tick randomness, still seeded — decorrelated from the
// first frame so the decode doesn't open on a repeat.
const random = mulberry32(seedBase ^ 0x9e3779b9);
const lockStep = Math.max(1, stagger);
let raf = 0;
let start: number | null = null;
let lastSwap = -Infinity;
const tick = (now: number) => {
if (start === null) start = now;
const elapsed = now - start - delay;
if (elapsed >= 0) {
const locked = Math.min(chars.length, Math.floor(elapsed / lockStep));
if (locked >= chars.length) {
setState({ display: chars, locked: chars.length });
return;
}
if (now - lastSwap >= speed) {
lastSwap = now;
setState({
display: chars.map((ch, i) =>
i < locked || /\s/.test(ch)
? ch
: glyphs[Math.floor(random() * glyphs.length)],
),
locked,
});
}
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [
inView,
reducedMotion,
isStatic,
once,
chars,
glyphs,
seedBase,
delay,
stagger,
speed,
initialScramble,
]);
if (isStatic) {
return (
<span className={className} {...props}>
{children}
</span>
);
}
return (
<span ref={ref} data-slot="text-shuffle" className={className} {...props}>
<span className="sr-only">{children}</span>
<span aria-hidden="true">
{chars.map((ch, i) =>
/\s/.test(ch) ? (
<React.Fragment key={i}>{ch}</React.Fragment>
) : (
<span key={i} className="relative inline-block">
{/* Invisible final glyph reserves its exact width, so the
overlay swap never reflows the line — in any font. */}
<span className="invisible">{ch}</span>
<span
className={cn(
"absolute inset-0 text-center",
state.locked <= i && "text-muted-foreground",
)}
style={{
transitionProperty: "color",
transitionDuration: "var(--duration-quick)",
transitionTimingFunction: "var(--ease-out)",
}}
>
{state.display[i]}
</span>
</span>
),
)}
</span>
</span>
);
}