Text scramble
Text decodes from random glyphs into the real string, one character at a time from left to right.
It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.
Demo.tsx
"use client";
import { useCallback, useEffect, useRef } from "react";
import { useInView, usePrefersReducedMotion } from "@/lib/motion";
import styles from "./demo.module.css";
const GLYPHS = "ABCDEFGHJKLMNPQRSTUVWXYZ0123456789#%&*+=";
const LINES = ["STAY CONFIRMED", "REF VLA 4821 KQ"];
const SPOKEN = ["Stay confirmed", "Reference VLA 4821 KQ"];
export default function Demo() {
const root = useRef<HTMLDivElement>(null);
const lines = useRef<(HTMLSpanElement | null)[]>([]);
const raf = useRef(0);
const played = useRef(false);
const inView = useInView(root);
const reduced = usePrefersReducedMotion();
const scramble = useCallback(() => {
if (reduced) return;
cancelAnimationFrame(raf.current);
const start = performance.now();
// Elements are read once here: a frame can still run after unmount, when refs are already null.
const els = [...lines.current];
let last = 0;
const frame = (now: number) => {
if (now - last >= 50) {
last = now;
const t = now - start;
let done = true;
LINES.forEach((line, l) => {
let out = "";
for (let i = 0; i < line.length; i++) {
const settle = 200 + l * 220 + i * 45;
if (line[i] === " " || t >= settle) out += line[i];
else {
out += GLYPHS[Math.floor(Math.random() * GLYPHS.length)];
done = false;
}
}
els[l]!.textContent = out;
});
if (done) return;
}
raf.current = requestAnimationFrame(frame);
};
raf.current = requestAnimationFrame(frame);
}, [reduced]);
useEffect(() => {
if (inView && !played.current && !reduced) {
played.current = true;
scramble();
}
}, [inView, reduced, scramble]);
useEffect(() => () => cancelAnimationFrame(raf.current), []);
return (
<div ref={root} className={styles.root}>
<div className={styles.card} onPointerEnter={scramble}>
<p className={styles.label}>Villa Azul, 12 to 15 June</p>
{LINES.map((line, l) => (
<p key={line} className={l === 0 ? styles.title : styles.ref}>
<span className={styles.sr}>{SPOKEN[l]}</span>
<span aria-hidden="true" ref={(el) => { lines.current[l] = el; }}>{line}</span>
</p>
))}
</div>
</div>
);
}
demo.module.css
.root { container-type: size; width: 100%; height: 100%; display: grid; place-items: center; padding: 20px; }
.card { display: grid; gap: 10px; justify-items: center; text-align: center; padding: clamp(20px, 6cqi, 40px) clamp(20px, 7cqi, 56px); border-radius: 16px; background: var(--white); border: 1px solid var(--line); transition: border-color 0.18s; }
.card:hover { border-color: var(--accent-text); }
.label { font-size: 13px; font-weight: 600; color: var(--muted); }
.title, .ref { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; white-space: pre; }
.title { font-size: clamp(20px, min(6cqi, 11cqh), 40px); font-weight: 700; letter-spacing: 0.02em; line-height: 1.1; }
.ref { font-size: clamp(13px, 3.4cqi, 17px); color: var(--accent-text); letter-spacing: 0.08em; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) { .card { transition: none; } }
Use it for
- Booking references, codes and confirmations
- A short label on a technical or security themed page
- Hover feedback on a single card or tag
Avoid
- Long sentences, the noise makes them unreadable
- Proportional fonts, the width jumps as glyphs change
How it works
A requestAnimationFrame loop rewrites the line's textContent through a ref about every 50ms. Character i settles to its real value at 200ms plus 45ms times i, and every unsettled character shows a random glyph, so the text resolves left to right. It runs once when the card enters the viewport and again on pointerenter. A monospace face keeps the width steady.
Accessibility
The scrambled span is aria-hidden and a visually hidden copy holds the real text, so screen readers hear it once and never the noise. Reduced motion shows the final text and skips the loop.