Swish UI
All componentsText Free

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.

Found on