# Text Scramble

The TextScramble component decodes text one character at a time, cycling through random glyphs before each letter settles into place.

- **Category:** Text
- **Docs:** https://uibeats.com/docs/text/text-scramble
- **Registry:** https://uibeats.com/r/text-scramble.json
- **License:** MIT
- **npm dependencies:** `motion`

## Installation

```bash
npx shadcn@latest add https://uibeats.com/r/text-scramble.json
```

Or install the dependencies and copy the source below:

```bash
npm install motion
```

## When to use

A decode effect that suits technical and playful brands. It is decorative, so the settled text is what assistive technology announces. Keep the string short enough to finish before a reader looks away.

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` | `string` | `-` | The final text to decode to (required) |
| `characters` | `string` | `'!<>-_\\/[]{}—=+*^?#'` | Pool of glyphs cycled through before a letter settles |
| `speed` | `number` | `40` | Milliseconds between scramble frames |
| `scrambleCount` | `number` | `6` | Frames each character scrambles before locking in |
| `scrambleOnHover` | `boolean` | `true` | Re-run the effect when the pointer enters the text |
| `className` | `string` | `''` | Additional CSS classes for styling |

## Example usage

```tsx
"use client";
import { TextScramble } from "@/components/demo/text/text-scramble";

const TextScrambleUsage = () => {
  return (
    <TextScramble
      text="Supercharge your UI"
      speed={35}
      className="text-xl font-bold md:text-2xl"
    />
  );
};

export default TextScrambleUsage;
```

## Source

Save as `components/ui/text-scramble.tsx`.

```tsx
"use client";

import { useCallback, useEffect, useRef, useState } from "react";
import { useReducedMotion } from "motion/react";

interface TextScrambleProps {
  text: string;
  /** Characters cycled through before a letter settles. */
  characters?: string;
  /** Milliseconds between scramble frames. */
  speed?: number;
  /** Frames each character scrambles before locking in. */
  scrambleCount?: number;
  /** Re-run the effect whenever the pointer enters the text. */
  scrambleOnHover?: boolean;
  className?: string;
}

const DEFAULT_CHARS = "!<>-_\\/[]{}—=+*^?#________";

/**
 * Decodes text one character at a time, cycling through random glyphs before
 * each letter settles.
 */
export function TextScramble({
  text,
  characters = DEFAULT_CHARS,
  speed = 40,
  scrambleCount = 6,
  scrambleOnHover = true,
  className = "",
}: TextScrambleProps) {
  const prefersReducedMotion = useReducedMotion();
  const [display, setDisplay] = useState(text);
  const [renderedText, setRenderedText] = useState(text);
  const frameRef = useRef<ReturnType<typeof setInterval> | null>(null);

  // React's "adjust state when a prop changes" pattern: resetting during render
  // rather than in an effect avoids a wasted commit with stale text on screen.
  if (text !== renderedText) {
    setRenderedText(text);
    setDisplay(text);
  }

  const stop = useCallback(() => {
    if (frameRef.current) {
      clearInterval(frameRef.current);
      frameRef.current = null;
    }
  }, []);

  /**
   * Starts the scramble interval. Deliberately performs no synchronous state
   * update, so it is safe to call from an effect body: the timer is the
   * external system the effect subscribes to.
   */
  const run = useCallback(() => {
    if (prefersReducedMotion) return;

    stop();
    let frame = 0;

    frameRef.current = setInterval(() => {
      const revealed = Math.floor(frame / scrambleCount);

      if (revealed >= text.length) {
        setDisplay(text);
        stop();
        return;
      }

      const next = text
        .split("")
        .map((char, index) => {
          if (index < revealed || char === " ") return char;
          return (
            characters[Math.floor(Math.random() * characters.length)] ?? char
          );
        })
        .join("");

      setDisplay(next);
      frame += 1;
    }, speed);
  }, [characters, prefersReducedMotion, scrambleCount, speed, stop, text]);

  // `display` starts as the final text, so the server-rendered markup and the
  // reduced-motion path both show real, readable content.
  useEffect(() => {
    run();
    return stop;
  }, [run, stop]);

  return (
    <span
      className={`inline-block font-mono tabular-nums ${className}`}
      onPointerEnter={scrambleOnHover ? run : undefined}
    >
      {/* The settled text is what assistive tech announces; the scrambling
          frames are decorative noise. */}
      <span aria-hidden="true">{display}</span>
      <span className="sr-only">{text}</span>
    </span>
  );
}

export default TextScramble;
```

## Credits

Written by [Claude Code](https://claude.com/claude-code).
