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