Scramble Text

Reveals text with a randomized character scramble/glitch effect. Perfect for status messages, dynamic content, or adding visual interest to text transitions.

Made by Léo
Open in

Asking the rubber duck

demo-scramble-text.tsx
"use client";

import { useEffect, useRef, useState } from "react";

import {
  ScrambleText,
  ScrambleTextHandle,
} from "@/components/unlumen-ui/scramble-text";

const CHAOS_VERBS = [
  "Asking the rubber duck",
  "Flipping the table",
  "Yelling into the void",
  "Performing dark rituals",
  "Sacrificing a semicolon",
  "Consulting the Magic 8-Ball",
  "Rolling for initiative",
  "Blaming cosmic rays",
  "Summoning Cthulhu",
  "Dividing by zero",
  "Feeding the chaos monkey",
  "Releasing the kraken",
  "Rearranging deck chairs on the Titanic",
  "Poking the bear",
  "Opening Pandora's box",
  "Unplugging and plugging back in",
  "Smashing the keyboard",
  "Consulting the ouija board",
  "Generating random numbers by rolling dice",
  "Letting the intrusive thoughts win",
  "Stack-overflowing the stack overflow",
  "Applying percussive maintenance",
  "Crossing the streams",
  "Playing Russian roulette with git force push",
  "Turning the chaos up to eleven",
  "Invoking Murphy's Law",
  "Ignoring all the red flags",
  "Vibing with the segfault",
  "Tempting fate",
  "Gazing upon forbidden knowledge",
  "Questioning all life choices",
  "Embracing the entropy",
  "Hitting random buttons",
  "Trusting the process (blindly)",
  "Entering the danger zone",
];

export function ScrambleTextDemo() {
  const [currentIndex, setCurrentIndex] = useState(0);
  const [key, setKey] = useState(0);
  const ref = useRef<ScrambleTextHandle>(null);

  useEffect(() => {
    const interval = setInterval(() => {
      setCurrentIndex((prev) => (prev + 1) % CHAOS_VERBS.length);
      setKey((k) => k + 1);
    }, 2000);

    return () => clearInterval(interval);
  }, []);

  return (
    <div className="flex items-center justify-center py-12 px-4">
      <div className="text-center">
        <p className="text-xl font-mono  tracking-tight">
          <ScrambleText
            key={key}
            ref={ref}
            text={CHAOS_VERBS[currentIndex]}
            scrambledClassName="text-[#E07947]"
            scrambleSpeed={30}
          />
        </p>
      </div>
    </div>
  );
}

Overview

The Scramble Text component animates text by revealing characters one by one, with unrevealed characters displaying random glitches. It's ideal for loading states, status updates, code snippets, or any moment where you want text to have impact.

Installation

Copy and paste the following code into your project:

components/unlumen-ui/scramble-text.tsx
"use client";

import {
  forwardRef,
  useCallback,
  useEffect,
  useImperativeHandle,
  useState,
} from "react";

interface ScrambleTextProps {
  text: string;
  /** @default 50 */
  scrambleSpeed?: number;
  /** scrambled chars shown ahead of the revealed text — @default 2 */
  scrambledLetterCount?: number;
  characters?: string;
  className?: string;
  /** class for the scrambled (unrevealed) portion */
  scrambledClassName?: string;
  /** @default true */
  autoStart?: boolean;
  delay?: number;
  onStart?: () => void;
  onComplete?: () => void;
}

export interface ScrambleTextHandle {
  start: () => void;
  reset: () => void;
}

const ScrambleText = forwardRef<ScrambleTextHandle, ScrambleTextProps>(
  (
    {
      text,
      scrambleSpeed = 50,
      scrambledLetterCount = 2,
      characters = "abcdefghijklmnopqrstuvwxyz!@#$%^&*()_+",
      className = "",
      scrambledClassName = "",
      autoStart = true,
      delay = 0,
      onStart,
      onComplete,
    },
    ref,
  ) => {
    const [displayText, setDisplayText] = useState("");
    const [isAnimating, setIsAnimating] = useState(false);
    const [visibleLetterCount, setVisibleLetterCount] = useState(0);
    const [scrambleOffset, setScrambleOffset] = useState(0);

    const startAnimation = useCallback(() => {
      setIsAnimating(true);
      setVisibleLetterCount(0);
      setScrambleOffset(0);
      onStart?.();
    }, [onStart]);

    const reset = useCallback(() => {
      setIsAnimating(false);
      setVisibleLetterCount(0);
      setScrambleOffset(0);
      setDisplayText("");
    }, []);

    useImperativeHandle(ref, () => ({
      start: startAnimation,
      reset,
    }));

    useEffect(() => {
      if (!autoStart) return;
      if (delay > 0) {
        const t = setTimeout(startAnimation, delay);
        return () => clearTimeout(t);
      }
      startAnimation();
    }, [autoStart, delay, startAnimation]);

    useEffect(() => {
      let interval: NodeJS.Timeout;

      if (isAnimating) {
        interval = setInterval(() => {
          if (visibleLetterCount < text.length) {
            setVisibleLetterCount((prev) => prev + 1);
          } else if (scrambleOffset < scrambledLetterCount) {
            setScrambleOffset((prev) => prev + 1);
          } else {
            clearInterval(interval);
            setIsAnimating(false);
            onComplete?.();
          }

          const remainingSpace = Math.max(0, text.length - visibleLetterCount);
          const currentScrambleCount = Math.min(
            remainingSpace,
            scrambledLetterCount,
          );

          const scrambledPart = Array(currentScrambleCount)
            .fill(0)
            .map(
              () => characters[Math.floor(Math.random() * characters.length)],
            )
            .join("");

          setDisplayText(text.slice(0, visibleLetterCount) + scrambledPart);
        }, scrambleSpeed);
      }

      return () => {
        if (interval) clearInterval(interval);
      };
    }, [
      isAnimating,
      text,
      visibleLetterCount,
      scrambleOffset,
      scrambledLetterCount,
      characters,
      scrambleSpeed,
      onComplete,
    ]);

    const renderText = () => {
      const revealed = displayText.slice(0, visibleLetterCount);
      const scrambled = displayText.slice(visibleLetterCount);

      return (
        <>
          <span className={className}>{revealed}</span>
          <span className={scrambledClassName}>{scrambled}</span>
        </>
      );
    };

    return (
      <>
        <span className="sr-only">{text}</span>
        <span className="inline-block whitespace-pre-wrap" aria-hidden="true">
          {renderText()}
        </span>
      </>
    );
  },
);

ScrambleText.displayName = "ScrambleText";

export { ScrambleText };
export default ScrambleText;

Update the import paths to match your project setup.

Usage

import { ScrambleText } from "@/components/unlumen-ui/scramble-text";

Basic

<ScrambleText text="Hello, World." />

Custom styling

<ScrambleText text="Access granted." scrambledClassName="text-orange-500" />

Manual control

import { useRef } from "react";
import {
  ScrambleText,
  ScrambleTextHandle,
} from "@/components/unlumen-ui/scramble-text";

export function Example() {
  const ref = useRef<ScrambleTextHandle>(null);

  return (
    <>
      <ScrambleText ref={ref} text="Click to reveal" autoStart={false} />
      <button onClick={() => ref.current?.start()}>Start</button>
      <button onClick={() => ref.current?.reset()}>Reset</button>
    </>
  );
}

Auto-cycling through phrases

import { useEffect, useRef, useState } from "react";
import {
  ScrambleText,
  ScrambleTextHandle,
} from "@/components/unlumen-ui/scramble-text";

export function Example() {
  const [currentIndex, setCurrentIndex] = useState(0);
  const [key, setKey] = useState(0);
  const ref = useRef<ScrambleTextHandle>(null);
  const phrases = ["Loading...", "Processing...", "Done!"];

  useEffect(() => {
    const interval = setInterval(() => {
      setCurrentIndex((prev) => (prev + 1) % phrases.length);
      setKey((k) => k + 1);
    }, 2000);
    return () => clearInterval(interval);
  }, []);

  return (
    <ScrambleText
      key={key}
      ref={ref}
      text={phrases[currentIndex]}
      scrambleSpeed={30}
    />
  );
}

Props

PropTypeDefaultDescription
textstringThe final text to reveal.
scrambleSpeednumber50Interval in ms between each animation tick.
scrambledLetterCountnumber2Number of scrambled characters shown ahead of revealed text.
charactersstring"abcdefghijklmnopqrstuvwxyz!@#…"Pool of characters used for the scramble effect.
classNamestringClass applied to the revealed portion of text.
scrambledClassNamestringClass applied to the scrambled (unrevealed) portion of text.
autoStartbooleantrueWhether to start the animation automatically on mount.
delaynumber0Delay in ms before autoStart fires.
onStart() => voidCallback fired when the animation begins.
onComplete() => voidCallback fired when the animation completes.

Methods

MethodDescription
start()Starts (or restarts) the scramble animation.
reset()Stops the animation and clears the text.

Built by Léo from Unlumen :3

Last updated: 3/15/2026