Dirck Mulder
Text Animations||3 min read

Build a Decrypted Text Effect in React

Reveal text by scrambling through random characters that resolve one by one, mimicking a decryption sequence.

There is something cinematic about watching encrypted text decode itself in real time. Think spy films, hacker interfaces, classified document reveals. The Decrypted Text component brings that energy to your React app.

The final result

DecryptedText scrambles each character through random symbols, then resolves them in sequence until the full intended text is visible. It supports hover, click, and scroll-into-view triggers, sequential or simultaneous reveals, and even a reverse mode that re-encrypts on mouse leave.

Setting up

This uses Motion for the container and an IntersectionObserver for scroll triggering. The core scramble logic is plain React state.

tsx
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { motion } from 'motion/react';

Building the component

The key state variables track what is currently displayed, which indices have been revealed, and whether we are animating forward or in reverse.

tsx
const [displayText, setDisplayText] = useState<string>(text);
const [isAnimating, setIsAnimating] = useState<boolean>(false);
const [revealedIndices, setRevealedIndices] = useState<Set<number>>(new Set());
const [direction, setDirection] = useState<Direction>('forward');

The shuffleText function builds the display string. Revealed positions show the real character, unrevealed positions show a random one from the character pool.

tsx
const shuffleText = useCallback((originalText: string, currentRevealed: Set<number>) => {
  return originalText.split('').map((char, i) => {
    if (char === ' ') return ' ';
    if (currentRevealed.has(i)) return originalText[i];
    return availableChars[Math.floor(Math.random() * availableChars.length)];
  }).join('');
}, [availableChars]);

The computeOrder function determines the reveal sequence. start goes left to right, end goes right to left, and center works outward from the middle.

tsx
const computeOrder = useCallback((len: number): number[] => {
  if (revealDirection === 'start') {
    return Array.from({ length: len }, (_, i) => i);
  }
  if (revealDirection === 'end') {
    return Array.from({ length: len }, (_, i) => len - 1 - i);
  }
  // center: alternate left and right from the middle
  const middle = Math.floor(len / 2);
  let offset = 0;
  const order: number[] = [];
  while (order.length < len) {
    const idx = offset % 2 === 0 ? middle + offset / 2 : middle - Math.ceil(offset / 2);
    if (idx >= 0 && idx < len) order.push(idx);
    offset++;
  }
  return order;
}, [revealDirection]);

The animation loop runs on a setInterval with the configured speed. In sequential mode, one new index is revealed per tick. In non-sequential mode, the whole display string gets reshuffled each tick until the max iterations are reached.

tsx
const interval = setInterval(() => {
  setRevealedIndices(prevRevealed => {
    if (sequential && direction === 'forward') {
      if (prevRevealed.size < text.length) {
        const nextIndex = getNextIndex(prevRevealed);
        const newRevealed = new Set(prevRevealed);
        newRevealed.add(nextIndex);
        setDisplayText(shuffleText(text, newRevealed));
        return newRevealed;
      } else {
        clearInterval(interval);
        setIsAnimating(false);
        setIsDecrypted(true);
        return prevRevealed;
      }
    }
    // non-sequential: reshuffle everything, stop after maxIterations
  });
}, speed);

How to use it

tsx
<DecryptedText
  text="CLASSIFIED"
  animateOn="view"
  sequential={true}
  revealDirection="start"
  speed={50}
  characters="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!@#$%^&*()"
  className="text-green-400 font-mono"
  encryptedClassName="text-green-800 font-mono"
/>

Set animateOn="hover" with clickMode="toggle" to re-encrypt on mouse leave.

Key takeaways

  • Using a Set for revealedIndices makes it cheap to check whether a position has been revealed and to add new ones each tick.
  • Rendering two span layers, one visible and one screen-reader-only with the real text, ensures accessibility even while the display is scrambled.
  • The encryptedClassName prop lets you style scrambled characters differently from revealed ones, which amplifies the decode-in-progress visual.