Scramble Text
Text decodes from random characters, on view or on every hover.
Hover the links
components/ui/scramble-text.tsx
"use client";
import { useInView } from "motion/react";
import { useCallback, useEffect, useRef, useState } from "react";
const CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#%&*+=<>/";
type ScrambleTextProps = {
text: string;
className?: string;
/** Milliseconds until the text is fully decoded */
duration?: number;
/** "view" decodes once when visible, "hover" re-decodes on every hover */
trigger?: "view" | "hover";
};
export function ScrambleText({ text, className = "", duration = 900, trigger = "view" }: ScrambleTextProps) {
const [display, setDisplay] = useState(text);
const ref = useRef<HTMLSpanElement>(null);
const frame = useRef(0);
const inView = useInView(ref, { once: true, amount: 0.8 });
const scramble = useCallback(() => {
cancelAnimationFrame(frame.current);
const start = performance.now();
const tick = (now: number) => {
const progress = Math.min((now - start) / duration, 1);
const revealed = Math.floor(progress * text.length);
setDisplay(
text
.split("")
.map((c, i) => (c === " " || i < revealed ? c : CHARS[Math.floor(Math.random() * CHARS.length)]))
.join(""),
);
if (progress < 1) frame.current = requestAnimationFrame(tick);
};
frame.current = requestAnimationFrame(tick);
}, [text, duration]);
useEffect(() => {
if (trigger === "view" && inView) scramble();
}, [trigger, inView, scramble]);
useEffect(() => () => cancelAnimationFrame(frame.current), []);
return (
<span ref={ref} aria-label={text} onMouseEnter={trigger === "hover" ? scramble : undefined} className={`font-mono ${className}`}>
<span aria-hidden>{display}</span>
</span>
);
}
export default function Demo() {
return (
<div className="text-center">
<ScrambleText text="SYSTEM ONLINE" className="text-4xl font-semibold tracking-tight text-zinc-900" duration={1200} />
<div className="mt-8 flex flex-wrap justify-center gap-6 text-sm text-zinc-500">
{["DOCS", "PRICING", "CHANGELOG", "CONTACT"].map((t) => (
<ScrambleText key={t} text={t} trigger="hover" className="cursor-default hover:text-blue-600" />
))}
</div>
<p className="mt-3 text-xs text-zinc-400">Hover the links</p>
</div>
);
}
Installation
Install the dependencies
$ npm i motion lucide-reactCopy the source into
components/ui/scramble-text.tsxcomponents/ui/scramble-text.tsx"use client"; import { useInView } from "motion/react"; import { useCallback, useEffect, useRef, useState } from "react"; const CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#%&*+=<>/"; type ScrambleTextProps = { text: string; className?: string; /** Milliseconds until the text is fully decoded */ duration?: number; /** "view" decodes once when visible, "hover" re-decodes on every hover */ trigger?: "view" | "hover"; }; export function ScrambleText({ text, className = "", duration = 900, trigger = "view" }: ScrambleTextProps) { const [display, setDisplay] = useState(text); const ref = useRef<HTMLSpanElement>(null); const frame = useRef(0); const inView = useInView(ref, { once: true, amount: 0.8 }); const scramble = useCallback(() => { cancelAnimationFrame(frame.current); const start = performance.now(); const tick = (now: number) => { const progress = Math.min((now - start) / duration, 1); const revealed = Math.floor(progress * text.length); setDisplay( text .split("") .map((c, i) => (c === " " || i < revealed ? c : CHARS[Math.floor(Math.random() * CHARS.length)])) .join(""), ); if (progress < 1) frame.current = requestAnimationFrame(tick); }; frame.current = requestAnimationFrame(tick); }, [text, duration]); useEffect(() => { if (trigger === "view" && inView) scramble(); }, [trigger, inView, scramble]); useEffect(() => () => cancelAnimationFrame(frame.current), []); return ( <span ref={ref} aria-label={text} onMouseEnter={trigger === "hover" ? scramble : undefined} className={`font-mono ${className}`}> <span aria-hidden>{display}</span> </span> ); } export default function Demo() { return ( <div className="text-center"> <ScrambleText text="SYSTEM ONLINE" className="text-4xl font-semibold tracking-tight text-zinc-900" duration={1200} /> <div className="mt-8 flex flex-wrap justify-center gap-6 text-sm text-zinc-500"> {["DOCS", "PRICING", "CHANGELOG", "CONTACT"].map((t) => ( <ScrambleText key={t} text={t} trigger="hover" className="cursor-default hover:text-blue-600" /> ))} </div> <p className="mt-3 text-xs text-zinc-400">Hover the links</p> </div> ); }Use it
The file exports the component and a
Demoshowing typical usage. Import whichever you need.