effects#text#animation

Text Scramble Reveal

Text that starts scrambled with random characters, then resolves letter-by-letter into the final message

Implementation Guide
# Text Scramble Reveal Effect

> Text that starts scrambled with random characters, then resolves letter-by-letter into the final message. Perfect for headlines, hero text, or loading states.

## Quick Start

```tsx
import TextScramble from '@/components/examples/effects/text-scramble';

export default function Hero() {
  return (
    <div className="h-screen">
      <TextScramble text="WELCOME TO THE FUTURE" />
    </div>
  );
}
```

## Props

| Prop          | Type                          | Default                     | Description                                |
| ------------- | ----------------------------- | --------------------------- | ------------------------------------------ |
| text          | string                        | "DESIGN RAILS"              | The text to reveal                         |
| scrambleChars | string                        | "!<>-\_\\/[]{}—=+\*^?#@$%&" | Characters used for scrambling effect      |
| speed         | number                        | 50                          | Milliseconds between animation frames      |
| delay         | number                        | 0                           | Initial delay before animation starts (ms) |
| trigger       | 'auto' \| 'hover' \| 'inView' | 'auto'                      | When to trigger the animation              |
| className     | string                        | ''                          | Additional CSS classes for the container   |

## Full Implementation

```tsx
'use client';

import { useEffect, useRef, useState, useCallback } from 'react';

interface CharacterState {
  char: string;
  isResolved: boolean;
  scrambleChar: string;
}

interface TextScrambleProps {
  text?: string;
  scrambleChars?: string;
  speed?: number;
  delay?: number;
  trigger?: 'auto' | 'hover' | 'inView';
  className?: string;
}

export default function TextScramble({
  text = 'DESIGN RAILS',
  scrambleChars = '!<>-_\\/[]{}—=+*^?#@$%&',
  speed = 50,
  delay = 0,
  trigger = 'auto',
  className = '',
}: TextScrambleProps) {
  const [characters, setCharacters] = useState<CharacterState[]>([]);
  const [isAnimating, setIsAnimating] = useState(false);
  const [hasStarted, setHasStarted] = useState(false);
  const containerRef = useRef<HTMLDivElement>(null);
  const intervalRef = useRef<NodeJS.Timeout | null>(null);
  const resolveQueueRef = useRef<number[]>([]);
  const prefersReducedMotionRef = useRef(false);

  const getRandomScrambleChar = useCallback(() => {
    return scrambleChars[Math.floor(Math.random() * scrambleChars.length)];
  }, [scrambleChars]);

  const initializeCharacters = useCallback(() => {
    if (prefersReducedMotionRef.current) {
      setCharacters(
        text.split('').map((char) => ({
          char,
          isResolved: true,
          scrambleChar: char,
        }))
      );
      return;
    }

    setCharacters(
      text.split('').map((char) => ({
        char,
        isResolved: char === ' ',
        scrambleChar: char === ' ' ? ' ' : getRandomScrambleChar(),
      }))
    );
  }, [text, getRandomScrambleChar]);

  const startAnimation = useCallback(() => {
    if (hasStarted || prefersReducedMotionRef.current) return;

    setHasStarted(true);
    setIsAnimating(true);

    // Create a shuffled queue of character indices to resolve
    const nonSpaceIndices = text
      .split('')
      .map((char, index) => (char !== ' ' ? index : -1))
      .filter((index) => index !== -1);

    const shuffledIndices = [...nonSpaceIndices].sort(() => Math.random() - 0.5);

    shuffledIndices.forEach((charIndex, queuePosition) => {
      resolveQueueRef.current[charIndex] = queuePosition;
    });

    let currentResolvePosition = 0;
    let tickCount = 0;

    intervalRef.current = setInterval(() => {
      tickCount++;

      setCharacters((prevChars) => {
        const newChars = prevChars.map((charState, index) => {
          if (charState.isResolved) return charState;

          const resolvePosition = resolveQueueRef.current[index];
          const shouldResolve =
            resolvePosition !== undefined && resolvePosition < currentResolvePosition;

          if (shouldResolve) {
            return {
              ...charState,
              isResolved: true,
              scrambleChar: charState.char,
            };
          }

          return {
            ...charState,
            scrambleChar: getRandomScrambleChar(),
          };
        });

        const allResolved = newChars.every((c) => c.isResolved);
        if (allResolved && intervalRef.current) {
          clearInterval(intervalRef.current);
          intervalRef.current = null;
          setIsAnimating(false);
        }

        return newChars;
      });

      // Resolve one character every 2 ticks
      if (tickCount % 2 === 0) {
        currentResolvePosition++;
      }
    }, speed);
  }, [text, speed, hasStarted, getRandomScrambleChar]);

  const resetAnimation = useCallback(() => {
    if (intervalRef.current) {
      clearInterval(intervalRef.current);
      intervalRef.current = null;
    }
    resolveQueueRef.current = [];
    setHasStarted(false);
    setIsAnimating(false);
    initializeCharacters();
  }, [initializeCharacters]);

  useEffect(() => {
    prefersReducedMotionRef.current = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

    initializeCharacters();

    return () => {
      if (intervalRef.current) {
        clearInterval(intervalRef.current);
      }
    };
  }, [initializeCharacters]);

  useEffect(() => {
    if (trigger === 'auto' && !hasStarted) {
      const timeoutId = setTimeout(() => {
        startAnimation();
      }, delay);

      return () => clearTimeout(timeoutId);
    }
  }, [trigger, delay, hasStarted, startAnimation]);

  useEffect(() => {
    if (trigger !== 'inView') return;

    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting && !hasStarted) {
            setTimeout(() => startAnimation(), delay);
          }
        });
      },
      { threshold: 0.5 }
    );

    if (containerRef.current) {
      observer.observe(containerRef.current);
    }

    return () => observer.disconnect();
  }, [trigger, delay, hasStarted, startAnimation]);

  const handleMouseEnter = () => {
    if (trigger === 'hover') {
      resetAnimation();
      setTimeout(() => startAnimation(), 50);
    }
  };

  return (
    <div
      ref={containerRef}
      className={`flex h-full w-full items-center justify-center bg-[#0a0a0a] ${className}`}
      onMouseEnter={handleMouseEnter}
    >
      <div className="flex flex-col items-center justify-center gap-6 p-8">
        <h1
          className="font-mono text-4xl font-bold tracking-tight text-white select-none sm:text-5xl md:text-6xl lg:text-7xl"
          aria-label={text}
        >
          {characters.map((charState, index) => (
            <span
              key={index}
              className={`inline-block transition-colors duration-150 ${
                charState.isResolved ? 'text-white' : 'text-[#007AFF]'
              }`}
              style={{
                minWidth: charState.char === ' ' ? '0.3em' : undefined,
              }}
            >
              {charState.scrambleChar}
            </span>
          ))}
        </h1>

        <div className="mt-8 flex flex-col items-center gap-4">
          <p className="font-mono text-sm tracking-widest text-gray-500 uppercase">
            {isAnimating ? 'Decoding...' : hasStarted ? 'Complete' : 'Waiting...'}
          </p>

          {trigger === 'hover' && (
            <p className="font-mono text-xs text-gray-600">Hover to replay</p>
          )}

          {trigger === 'auto' && hasStarted && !isAnimating && (
            <button
              onClick={resetAnimation}
              className="border border-gray-700 px-4 py-2 font-mono text-xs tracking-wider text-gray-400 uppercase transition-colors hover:border-gray-500 hover:text-gray-300"
            >
              Replay
            </button>
          )}
        </div>
      </div>
    </div>
  );
}
```

## Customization

### Different Speeds

Control the animation pacing with the `speed` prop:

```tsx
// Slow, dramatic reveal
<TextScramble text="LOADING..." speed={100} />

// Fast, energetic reveal
<TextScramble text="WELCOME" speed={25} />
```

### Custom Scramble Characters

Use different character sets for different vibes:

```tsx
// Binary/Matrix style
<TextScramble
  text="SYSTEM ONLINE"
  scrambleChars="01"
/>

// Numeric countdown feel
<TextScramble
  text="LAUNCH SEQUENCE"
  scrambleChars="0123456789"
/>

// Minimal dots and dashes
<TextScramble
  text="CONNECTING"
  scrambleChars=".-_"
/>

// Full ASCII chaos
<TextScramble
  text="DECRYPTING"
  scrambleChars="!@#$%^&*()_+-=[]{}|;:',.<>?/~`"
/>
```

### Trigger Modes

Control when the animation starts:

```tsx
// Auto-start with delay
<TextScramble
  text="WELCOME"
  trigger="auto"
  delay={1000}
/>

// Trigger on hover (replays each time)
<TextScramble
  text="HOVER ME"
  trigger="hover"
/>

// Trigger when scrolled into view
<TextScramble
  text="SCROLL TO REVEAL"
  trigger="inView"
  delay={200}
/>
```

### Multiple Lines

Create staggered multi-line reveals:

```tsx
function HeroText() {
  return (
    <div className="space-y-4">
      <TextScramble text="THE FUTURE" delay={0} />
      <TextScramble text="IS NOW" delay={800} />
      <TextScramble text="BUILD IT" delay={1600} />
    </div>
  );
}
```

### Light Background Variant

Override the default dark background:

```tsx
<TextScramble text="LIGHT MODE" className="!bg-white" />

// Note: You may need to also override text colors via CSS
```

## Performance Considerations

- **Interval-based animation**: Uses `setInterval` rather than `requestAnimationFrame` for predictable timing
- **State batching**: Character updates are batched in a single state update per tick
- **Cleanup**: Intervals are properly cleaned up on unmount and reset
- **Avoid long texts**: For texts over 50 characters, consider increasing `speed` to reduce render frequency
- **Monospace fonts**: Using monospace ensures consistent widths during scramble, preventing layout shifts

## Accessibility

- **`aria-label`**: The actual text is provided as an `aria-label` so screen readers announce the correct content immediately
- **`prefers-reduced-motion`**: When the user prefers reduced motion, the text displays immediately without animation
- **High contrast**: Unresolved characters use a distinct color (blue) from resolved characters (white) for visual feedback
- **Screen reader friendly**: The decorative scramble animation is purely visual; assistive technologies receive the final text

## Browser Support

- Modern browsers with ES6+ support
- `IntersectionObserver` required for `trigger="inView"` (widely supported)
- CSS `transition` support for color transitions
- `matchMedia` for reduced motion detection

## Common Use Cases

1. **Hero headlines**: Create impactful first impressions
2. **Loading states**: Show progress while data loads
3. **Reveals**: Dramatic content reveals on scroll
4. **Interactive elements**: Hover-triggered effects on buttons or links
5. **Countdowns**: Combined with timers for launch pages
6. **Error messages**: Stylized error or status displays