A decode-in reveal where characters churn through random glyphs and lock into the real letters left-to-right with a subtle deblur.
npx shadcn@latest add @paragon/scramble-in-text"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
const DEFAULT_GLYPHS = "▚▞░▒▓█<>/\\=+*|";
/** 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;
}
/** True on devices with a real hover pointer; SSR assumes fine and corrects after mount. */
function useFinePointer(): boolean {
const subscribe = React.useCallback((onChange: () => void) => {
const mql = window.matchMedia("(hover: hover) and (pointer: fine)");
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}, []);
return React.useSyncExternalStore(
subscribe,
() => window.matchMedia("(hover: hover) and (pointer: fine)").matches,
() => true,
);
}
export interface ScrambleInTextProps
extends Omit<React.ComponentProps<"span">, "children"> {
/** The final string the decode resolves into. */
children: string;
/** Milliseconds each character churns before it locks to its final glyph. */
speed?: number;
/** Milliseconds between successive characters starting to decode (the wave). */
stagger?: number;
/** Milliseconds before the reveal begins. */
delay?: number;
/** Glyph pool the pre-decode characters churn through. */
glyphs?: string;
/** Seed for the deterministic scramble. Defaults to a hash of useId + text. */
seed?: number;
/** `view` — decode once on scroll-in. `hover` — replay on hover. */
trigger?: "view" | "hover";
/** Render the final text with no motion. */
static?: boolean;
}
interface Frame {
glyph: string[];
resolved: boolean[];
}
/** Every ~55ms the churning glyphs advance, so undecoded characters visibly flicker. */
const CHURN_MS = 55;
/**
* ScrambleInText — a decode-in reveal. On enter, every character churns through
* random glyphs and a left-to-right wave locks them to the real letters one
* after another, each snapping from a blurred, muted glyph into a crisp one.
* The wave is strictly directional: character `i` starts churning at `i *
* stagger` and resolves one dwell (`speed`) later, so the string always
* resolves from the left.
*
* Seeded (useId or `seed`) so server and client paint the same first frame — no
* Math.random during render. The full string stays available to screen readers;
* the churning glyphs are decorative. Cells reserve each final glyph's width so
* the line never reflows. Reduced motion (or `static`) shows the final text.
* `view` fires once on scroll-in; `hover` replays on fine pointers and falls
* back to `view` on touch so the text never sits undecoded.
*/
export function ScrambleInText({
children,
speed = 90,
stagger = 55,
delay = 0,
glyphs = DEFAULT_GLYPHS,
seed,
trigger = "view",
static: isStatic = false,
className,
...props
}: ScrambleInTextProps) {
const id = React.useId();
const ref = React.useRef<HTMLSpanElement>(null);
const inView = useInView(ref, { once: true, amount: 0.4 });
const reducedMotion = useReducedMotion() ?? false;
const finePointer = useFinePointer();
const [hovered, setHovered] = React.useState(false);
const chars = React.useMemo(() => Array.from(children), [children]);
const glyphPool = React.useMemo(() => Array.from(glyphs), [glyphs]);
const seedBase = seed ?? hashString(id + children);
// First painted frame: every non-space character shows a seeded random glyph
// (deterministic, so SSR and the client agree), nothing resolved yet.
const initial = React.useMemo<Frame>(() => {
const rand = mulberry32(seedBase);
return {
glyph: chars.map((ch) =>
/\s/.test(ch)
? ch
: glyphPool[Math.floor(rand() * glyphPool.length)] ?? ch,
),
resolved: chars.map((ch) => /\s/.test(ch)),
};
}, [chars, glyphPool, seedBase]);
const [frame, setFrame] = React.useState<Frame>(initial);
// Reset during render when the text/glyphs/seed change (memo identity).
const [prevInitial, setPrevInitial] = React.useState(initial);
if (prevInitial !== initial) {
setPrevInitial(initial);
setFrame(initial);
}
const animated = !isStatic && !reducedMotion;
// Hover is a fine-pointer interaction; on touch the decode runs on view so
// the text never sits as undecoded glyphs waiting for an impossible hover.
const effectiveTrigger =
trigger === "hover" && !finePointer ? "view" : trigger;
const active = effectiveTrigger === "view" ? inView : hovered;
React.useEffect(() => {
if (!animated) return;
if (!active) {
// Hover replay: re-scramble while idle so the next hover decodes fresh.
if (effectiveTrigger === "hover") setFrame(initial);
return;
}
const rand = mulberry32(seedBase ^ 0x9e3779b9);
let raf = 0;
let start = 0;
let churnStep = -1;
let lastCommittedStep = -1;
let lastResolvedCount = -1;
let churnGlyphs = initial.glyph.slice();
const tick = (now: number) => {
if (!start) start = now;
const elapsed = now - start - delay;
// Advance the churn pool on a fixed cadence so undecoded glyphs flicker.
const step = elapsed <= 0 ? 0 : Math.floor(elapsed / CHURN_MS);
if (step !== churnStep) {
churnStep = step;
churnGlyphs = chars.map((ch, i) =>
/\s/.test(ch)
? ch
: glyphPool[Math.floor(rand() * glyphPool.length)] ??
churnGlyphs[i],
);
}
const glyph = new Array<string>(chars.length);
const resolved = new Array<boolean>(chars.length);
let allDone = true;
let resolvedCount = 0;
for (let i = 0; i < chars.length; i++) {
const ch = chars[i];
if (/\s/.test(ch)) {
glyph[i] = ch;
resolved[i] = true;
continue;
}
// Character i churns from `i*stagger` and locks one dwell later.
const lockAt = i * stagger + speed;
if (elapsed >= lockAt) {
glyph[i] = ch;
resolved[i] = true;
resolvedCount++;
} else {
resolved[i] = false;
allDone = false;
// Before its window opens keep the seeded initial glyph; once open,
// show the live churn glyph.
glyph[i] = elapsed >= i * stagger ? churnGlyphs[i] : initial.glyph[i];
}
}
// Commit only when the churn advanced, a character just resolved, or the
// decode finished — avoids a render on frames where nothing changed.
if (
allDone ||
step !== lastCommittedStep ||
resolvedCount !== lastResolvedCount
) {
lastCommittedStep = step;
lastResolvedCount = resolvedCount;
setFrame({ glyph, resolved });
}
if (allDone) return;
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [
animated,
active,
effectiveTrigger,
chars,
glyphPool,
seedBase,
delay,
speed,
stagger,
initial,
]);
if (isStatic || reducedMotion) {
return (
<span ref={ref} data-slot="scramble-in-text" className={className} {...props}>
{children}
</span>
);
}
return (
<span
ref={ref}
data-slot="scramble-in-text"
className={className}
onPointerEnter={
effectiveTrigger === "hover" ? () => setHovered(true) : undefined
}
onPointerLeave={
effectiveTrigger === "hover" ? () => setHovered(false) : undefined
}
{...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">
{/* Reserve the final glyph's width so the line never reflows. */}
<span className="invisible">{ch}</span>
<span
className={cn(
"absolute inset-0 text-center tabular-nums",
!frame.resolved[i] && "text-muted-foreground",
)}
style={{
filter: frame.resolved[i] ? "blur(0px)" : "blur(1.4px)",
transitionProperty: "filter, color",
transitionDuration: "var(--duration-quick)",
transitionTimingFunction: "var(--ease-out)",
}}
>
{frame.resolved[i] ? ch : frame.glyph[i]}
</span>
</span>
),
)}
</span>
</span>
);
}