Workspace / component / text
Decrypted Text
paxfx add blocks/component-text-decryptedlocal onlyTier: low. Reduced motion: Plain text renders immediately.
"use client";
import { useEffect, useMemo, useState } from "react";
import { cn, useInView, useReducedMotion } from "@pax-sh/blocks";
export interface DecryptedTextProps {
text?: string;
/** Heading level or inline element to render. */
as?: "h1" | "h2" | "h3" | "p" | "span";
/** Seconds for the full decode sweep. */
duration?: number;
/** Glyph pool used for the cipher mask. */
glyphs?: string;
className?: string;
}
const DEFAULT_GLYPHS = "!<>-_\\/[]{}~=+*^?#01";
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;
};
}
/**
* Reveals text through a cipher sweep: every glyph starts masked and
* decodes in a randomized order drawn from a seeded Fisher-Yates shuffle,
* so the sweep is identical on every run. Undecoded glyphs keep churning
* deterministically until their slot unlocks.
*/
export function DecryptedText({
text = "Access granted.",
as: Tag = "h2",
duration = 1.4,
glyphs = DEFAULT_GLYPHS,
className,
}: DecryptedTextProps) {
const reduced = useReducedMotion();
const [ref, inView] = useInView<HTMLSpanElement>({ threshold: 0.4 });
/** Rank of each character in the reveal order, from a seeded shuffle. */
const revealRank = useMemo(() => {
const chars = Array.from(text);
const order = chars.map((_, i) => i);
const rand = mulberry32(0x5150c0de);
for (let i = order.length - 1; i > 0; i -= 1) {
const j = Math.floor(rand() * (i + 1));
const a = order[i] as number;
order[i] = order[j] as number;
order[j] = a;
}
const rank = new Array<number>(chars.length).fill(0);
order.forEach((charIdx, r) => {
rank[charIdx] = r;
});
return rank;
}, [text]);
const masked = useMemo(
() =>
Array.from(text)
.map((char, i) => (char === " " ? " " : glyphs[(i * 5 + 3) % glyphs.length]))
.join(""),
[text, glyphs],
);
const [display, setDisplay] = useState(masked);
useEffect(() => {
if (reduced) {
setDisplay(text);
return;
}
if (!inView) {
setDisplay(masked);
return;
}
const chars = Array.from(text);
const span = Math.max(chars.length - 1, 1);
const totalMs = duration * 1000;
let raf = 0;
const start = performance.now();
const step = (now: number) => {
const elapsed = now - start;
if (elapsed >= totalMs) {
setDisplay(text);
return;
}
raf = requestAnimationFrame(step);
const tick = Math.floor(elapsed / 46);
const next = chars
.map((char, i) => {
if (char === " ") return " ";
const revealAt = ((revealRank[i] ?? 0) / span) * totalMs * 0.82;
if (elapsed >= revealAt) return char;
return glyphs[(i * 11 + tick * 5) % glyphs.length];
})
.join("");
setDisplay(next);
};
raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(raf);
}, [inView, reduced, text, duration, glyphs, masked, revealRank]);
return (
<Tag
className={cn("m-0 font-semibold tracking-tight", className)}
data-pax-asset="component-text-decrypted"
>
<span className="sr-only">{text}</span>
<span ref={ref} aria-hidden="true" className="inline-grid">
<span className="invisible col-start-1 row-start-1 whitespace-pre-wrap">{text}</span>
<span className="col-start-1 row-start-1 whitespace-pre-wrap">{display}</span>
</span>
</Tag>
);
}