The text decodes into place: random characters settle into the real ones from left to right. Good for short status labels and moments that should feel computed.
"use client";import { useRef, type ComponentPropsWithoutRef } from "react";import gsap from "gsap";import { ScrambleTextPlugin } from "gsap/ScrambleTextPlugin";import { useGSAP } from "@gsap/react";gsap.registerPlugin(ScrambleTextPlugin, useGSAP);type TextScrambleProps = Omit<ComponentPropsWithoutRef<"span">, "children"> & { text: string; duration?: number; revealDelay?: number; chars?: string;};export function TextScramble({ text, duration = 1, revealDelay = 0.3, chars = "upperCase", ...props}: TextScrambleProps) { const ref = useRef<HTMLSpanElement>(null); // react renders the first text once (for the server and no-js); after that gsap owns the node const first = useRef(text).current; useGSAP( () => { const el = ref.current!; if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { el.textContent = text; return; } // starts from whatever is showing, so a new text mid-scramble picks up where the old one was gsap.to(el, { duration, ease: "none", overwrite: true, scrambleText: { text, chars, revealDelay, speed: 0.5 }, }); }, { dependencies: [text, duration, revealDelay, chars] }, ); // screen readers get the final text once, not every scrambled frame return ( <span {...props}> <span className="sr-only">{text}</span> <span ref={ref} aria-hidden="true"> {first} </span> </span> );}ease: "none". Characters lock in at a steady rate, like a decoder. An eased scramble rushes, then stalls, and the stall reads as lag.
A short spell of pure noise before any letter locks in sets up the decode. Much longer and it starts to look broken.
A new text mid-scramble starts from whatever is on screen, so quick changes flow into each other instead of resetting.
Screen readers get the final text from a visually hidden copy; the scrambling characters are hidden from them. With reduced motion on, the text swaps instantly. Keep it to short labels, never body copy.