← All primitives
ScrambleText
MotionDecrypts its text from random glyphs to the target when it scrolls into view. rAF-stepped, zero deps. Use a monospace container to avoid reflow.
$
View raw manifest →npx shadcn@latest add https://prism.icglabs.co/r/scramble-text.jsoncomponents/prism/ScrambleText.tsx
"use client";
import { createElement, useEffect, useRef, useState, type ElementType } from "react";
/**
* ScrambleText — decrypts its text from random glyphs to the target when it
* scrolls into view. rAF-stepped, no deps. Use a monospace/tabular container to
* avoid per-frame reflow. Honors prefers-reduced-motion (shows final text).
*/
const GLYPHS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#%&@$*<>/\\";
export function ScrambleText({
text,
className = "",
duration = 1.1,
as: Tag = "span",
}: {
text: string;
className?: string;
/** seconds */
duration?: number;
as?: "span" | "h1" | "h2" | "h3" | "p";
}) {
const ref = useRef<HTMLSpanElement>(null);
const [display, setDisplay] = useState(text);
useEffect(() => {
const el = ref.current;
if (!el) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
setDisplay(text);
return;
}
let raf = 0;
let start = 0;
const step = (now: number) => {
if (!start) start = now;
const p = Math.min((now - start) / (duration * 1000), 1);
const revealed = Math.floor(p * text.length);
let out = "";
for (let i = 0; i < text.length; i++) {
if (i < revealed || text[i] === " ") out += text[i];
else out += GLYPHS[Math.floor(Math.random() * GLYPHS.length)];
}
setDisplay(out);
if (p < 1) raf = requestAnimationFrame(step);
else setDisplay(text);
};
const io = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
raf = requestAnimationFrame(step);
io.disconnect();
}
},
{ threshold: 0.5 }
);
io.observe(el);
return () => {
cancelAnimationFrame(raf);
io.disconnect();
};
}, [text, duration]);
const Comp = Tag as ElementType;
return createElement(
Comp,
{ ref, className, style: { fontVariantNumeric: "tabular-nums" }, "aria-label": text },
display
);
}