A decode-in reveal where block cursors resolve into 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;
}
export interface ScrambleInTextProps
extends Omit<React.ComponentProps<"span">, "children"> {
/** The final string the decode resolves into. */
children: string;
/** Milliseconds each character stays as a block cursor before decoding. */
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 cursor blocks cycle 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;
}
/**
* ScrambleInText — a decode-in reveal. On enter, every character starts as a
* dim block-cursor glyph and a left-to-right wave resolves them into the real
* letters one after another, each snapping from a blurred, muted block into a
* crisp glyph. Distinct from a churning shuffle: this is a directional
* *reveal* (undecoded characters read as terminal cursor blocks, not random
* letters), tuned to feel like text streaming out of a decoder.
*
* 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 blocks are decorative. Cells reserve each final glyph's width so
* the line never reflows. Reduced motion (or `static`) shows the final text.
* Pauses offscreen; `view` fires once, `hover` replays.
*/
export function ScrambleInText({
children,
speed = 42,
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: trigger === "view", amount: 0.4 });
const reducedMotion = useReducedMotion() ?? false;
const [hovered, setHovered] = React.useState(false);
const chars = React.useMemo(() => Array.from(children), [children]);
const seedBase = seed ?? hashString(id + children);
// -1 = not yet decoding, 0..1 = decoding progress unused; we track a lock
// index per char via a resolved count + a churn glyph for unresolved ones.
const initial = React.useMemo(() => {
const random = mulberry32(seedBase);
return chars.map((ch) =>
/\s/.test(ch) ? ch : glyphs[Math.floor(random() * glyphs.length)],
);
}, [chars, glyphs, seedBase]);
const [display, setDisplay] = React.useState<string[]>(initial);
const [resolved, setResolved] = React.useState<boolean[]>(() =>
chars.map(() => false),
);
// Reset on text change.
const [prev, setPrev] = React.useState(initial);
if (prev !== initial) {
setPrev(initial);
setDisplay(initial);
setResolved(chars.map(() => false));
}
const animated = !isStatic && !reducedMotion;
const active = trigger === "view" ? inView : hovered;
React.useEffect(() => {
if (!animated) return;
if (!active) {
if (trigger === "hover") {
setDisplay(initial);
setResolved(chars.map(() => false));
}
return;
}
const random = mulberry32(seedBase ^ 0x9e3779b9);
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) {
raf = requestAnimationFrame(tick);
return;
}
const nextResolved = chars.map((ch, i) => {
if (/\s/.test(ch)) return true;
// Each char resolves after its stagger offset + one dwell (speed).
return elapsed >= i * stagger + speed;
});
let changed = false;
let doneAll = true;
const swap = now - lastSwap >= speed;
if (swap) lastSwap = now;
const nextDisplay = chars.map((ch, i) => {
if (/\s/.test(ch)) return ch;
if (nextResolved[i]) return ch;
doneAll = false;
// Only start churning once this char's stagger window opens.
if (elapsed >= i * stagger && swap) {
changed = true;
return glyphs[Math.floor(random() * glyphs.length)];
}
return display[i];
});
setResolved((r) =>
r.some((v, i) => v !== nextResolved[i]) ? nextResolved : r,
);
if (changed || swap) setDisplay(nextDisplay);
if (doneAll) {
setDisplay(chars);
setResolved(chars.map(() => true));
return;
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [animated, active, trigger, chars, glyphs, 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={trigger === "hover" ? () => setHovered(true) : undefined}
onPointerLeave={trigger === "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">
<span className="invisible">{ch}</span>
<span
className={cn(
"absolute inset-0 text-center",
!resolved[i] && "text-muted-foreground/70",
)}
style={{
filter: resolved[i] ? "blur(0px)" : "blur(1.2px)",
transitionProperty: "filter, color",
transitionDuration: "var(--duration-quick)",
transitionTimingFunction: "var(--ease-out)",
}}
>
{resolved[i] ? ch : display[i]}
</span>
</span>
),
)}
</span>
</span>
);
}