Scramble Text

Text decodes from random characters, on view or on every hover.

Hover the links

Installation

  1. Install the dependencies

    $ npm i motion lucide-react
  2. Copy the source into components/ui/scramble-text.tsx

    components/ui/scramble-text.tsx
    "use client";
    
    import { useInView } from "motion/react";
    import { useCallback, useEffect, useRef, useState } from "react";
    
    const CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#%&*+=<>/";
    
    type ScrambleTextProps = {
      text: string;
      className?: string;
      /** Milliseconds until the text is fully decoded */
      duration?: number;
      /** "view" decodes once when visible, "hover" re-decodes on every hover */
      trigger?: "view" | "hover";
    };
    
    export function ScrambleText({ text, className = "", duration = 900, trigger = "view" }: ScrambleTextProps) {
      const [display, setDisplay] = useState(text);
      const ref = useRef<HTMLSpanElement>(null);
      const frame = useRef(0);
      const inView = useInView(ref, { once: true, amount: 0.8 });
    
      const scramble = useCallback(() => {
        cancelAnimationFrame(frame.current);
        const start = performance.now();
        const tick = (now: number) => {
          const progress = Math.min((now - start) / duration, 1);
          const revealed = Math.floor(progress * text.length);
          setDisplay(
            text
              .split("")
              .map((c, i) => (c === " " || i < revealed ? c : CHARS[Math.floor(Math.random() * CHARS.length)]))
              .join(""),
          );
          if (progress < 1) frame.current = requestAnimationFrame(tick);
        };
        frame.current = requestAnimationFrame(tick);
      }, [text, duration]);
    
      useEffect(() => {
        if (trigger === "view" && inView) scramble();
      }, [trigger, inView, scramble]);
    
      useEffect(() => () => cancelAnimationFrame(frame.current), []);
    
      return (
        <span ref={ref} aria-label={text} onMouseEnter={trigger === "hover" ? scramble : undefined} className={`font-mono ${className}`}>
          <span aria-hidden>{display}</span>
        </span>
      );
    }
    
    export default function Demo() {
      return (
        <div className="text-center">
          <ScrambleText text="SYSTEM ONLINE" className="text-4xl font-semibold tracking-tight text-zinc-900" duration={1200} />
          <div className="mt-8 flex flex-wrap justify-center gap-6 text-sm text-zinc-500">
            {["DOCS", "PRICING", "CHANGELOG", "CONTACT"].map((t) => (
              <ScrambleText key={t} text={t} trigger="hover" className="cursor-default hover:text-blue-600" />
            ))}
          </div>
          <p className="mt-3 text-xs text-zinc-400">Hover the links</p>
        </div>
      );
    }
    
  3. Use it

    The file exports the component and a Demo showing typical usage. Import whichever you need.