Skip to content
Motion

Scramble

Characters resolve left to right out of random glyphs, staggered so the string decodes as a wave rather than snapping. The detail that matters: the real string lives in a visually-hidden span, so assistive technology announces the final text once instead of a stream of noise as the effect runs. Every implementation that mutates textContent directly gets this wrong.

Loading preview…

Usage

/* Deepak Kumar E — https://craft.iam-deepak.space */ import { Scramble } from "@/components/scramble"; <Scramble />

Customise

22

The component

components/craft/motion/Scramble.tsx

/* Deepak Kumar E — https://craft.iam-deepak.space */ import { useCallback, useEffect, useRef, useState } from "react"; /** * Text that resolves out of noise, on view or on hover. * * The detail that separates this from the usual implementation: the element * always contains the FINAL text in the accessibility tree. The scrambling * happens in a visually-presented span while a visually-hidden span holds the * real string, so a screen reader announces "Founding Engineer" once, not a * stream of garbage characters as the effect runs — which is what you get * from every version that mutates textContent directly. * * Characters resolve left to right with a small random delay each, rather * than all at once. Uniform resolution reads as a loading state; staggered * resolution reads as decoding. */ const ALPHABETS = {  latin: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",  symbols: "!<>-_\\/[]{}—=+*^?#",  binary: "01",  katakana: "アイウエオカキクケコサシスセソタチツテトナニヌネノ",} as const; export type ScrambleAlphabet = keyof typeof ALPHABETS; export interface ScrambleProps {  text: string;  /** `view` fires once when scrolled into view; `hover` fires on pointer. */  trigger?: "view" | "hover" | "mount";  /** Frames each character spends scrambled before it locks. */  duration?: number;  alphabet?: ScrambleAlphabet;  /** Force the resolved text — reduced motion, print, screenshots. */  still?: boolean;  className?: string;} export function Scramble({  text,  trigger = "view",  duration = 22,  alphabet = "symbols",  still = false,  className = "",}: ScrambleProps) {  const [display, setDisplay] = useState(text);  const ref = useRef<HTMLSpanElement>(null);  const frame = useRef(0);  const running = useRef(false);   const run = useCallback(() => {    if (running.current) return;     const reduced =      still ||      (typeof matchMedia === "function" &&        matchMedia("(prefers-reduced-motion: reduce)").matches);    if (reduced) {      setDisplay(text);      return;    }     running.current = true;    const chars = ALPHABETS[alphabet];    // Each character gets its own window, staggered by index so the string    // resolves as a wave rather than snapping.    const schedule = Array.from({ length: text.length }, (_, i) => ({      start: i * 1.6,      end: i * 1.6 + duration * (0.6 + Math.random() * 0.8),    }));     let tick = 0;    const step = () => {      let done = true;      let output = "";       for (let i = 0; i < text.length; i += 1) {        const char = text[i];        // Whitespace is never scrambled: shuffling it changes the word        // shape and makes the line jitter as it resolves.        if (char === " ") {          output += " ";          continue;        }        if (tick >= schedule[i].end) {          output += char;        } else if (tick >= schedule[i].start) {          output += chars[Math.floor(Math.random() * chars.length)];          done = false;        } else {          output += chars[Math.floor(Math.random() * chars.length)];          done = false;        }      }       setDisplay(output);      tick += 1;       if (done) {        running.current = false;        setDisplay(text);        return;      }      frame.current = requestAnimationFrame(step);    };     frame.current = requestAnimationFrame(step);  }, [text, duration, alphabet, still]);   useEffect(() => {    if (trigger === "mount") {      // Deferred a frame rather than run inline. Starting it in the effect      // body sets state during commit, which cascades a second render before      // the first has painted — and the delay is one frame, so nobody sees it.      const start = requestAnimationFrame(run);      return () => {        cancelAnimationFrame(start);        cancelAnimationFrame(frame.current);      };    }     if (trigger !== "view") return;    const element = ref.current;    if (!element) return;     const observer = new IntersectionObserver(      ([entry]) => {        if (!entry.isIntersecting) return;        run();        // Once resolved it stays resolved — re-scrambling on every scroll        // past turns a flourish into a distraction.        observer.disconnect();      },      { threshold: 0.4 },    );     observer.observe(element);    return () => {      observer.disconnect();      cancelAnimationFrame(frame.current);    };  }, [trigger, run]);   useEffect(() => () => cancelAnimationFrame(frame.current), []);   return (    <span      ref={ref}      className={className}      onPointerEnter={trigger === "hover" ? run : undefined}    >      {/* The real string, for assistive technology and for select-and-copy. */}      <span className="sr-only">{text}</span>      <span aria-hidden>{display}</span>    </span>  );} 

One file. Paste it in, delete what you do not need, change what you do. The "use client" directive is stripped above — add it back if you are on the Next.js App Router.

What it costs

Weight

+1.6KB gzipped · no new dependencies

Runs on

Main thread

Runs JavaScript every frame. Heavy work here competes with your own code and with scrolling — watch the per-frame budget below.

Reduced motion

Skips the effect entirely and renders the final text immediately. Rapid character churn is a genuine trigger, not a taste preference.

Accessibility
  • The real string sits in an sr-only span, so it is announced once, correctly.
  • The scrambling span is aria-hidden.
  • Select-and-copy yields the real text, not the scrambled frame.
Watch out
  • Re-renders on every animation frame while running. Fine for a heading; do not put a hundred of them on one page.
  • Fires once per element and stays resolved — re-scrambling on every scroll past turns a flourish into a distraction.