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.
Usage
/* Deepak Kumar E — https://craft.iam-deepak.space */ import { Scramble } from "@/components/scramble"; <Scramble />Customise
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.