Text Decrypt

Text

Scramble-to-decode text reveal with left-to-right character locking.

Install
npx shadcn add https://design.helpmarq.com/r/text-decrypt.json
Source
registry/core/text-decrypt/component.tsx
"use client";

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

const CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#$%&@*+=";

export function DecryptText({
  text,
  delay = 150,
  stagger = 55,
  ambient = 0,
  className = "",
  replayKey = 0,
}: {
  text: string;
  /** ms before the first character locks */
  delay?: number;
  /** ms between character locks (left to right) */
  stagger?: number;
  /** ms between idle glitches re-scrambling a few characters; 0 = off */
  ambient?: number;
  className?: string;
  /** bump to replay the animation */
  replayKey?: number;
}) {
  const chars = Array.from(text);
  const [locked, setLocked] = useState(-1);
  const [churn, setChurn] = useState<string[]>(chars.map(() => ""));
  const [glitch, setGlitch] = useState<ReadonlySet<number>>(new Set());
  const reduced = useRef(false);

  const randomGlyph = useCallback(
    () => CHARSET[Math.floor(Math.random() * CHARSET.length)],
    []
  );

  useEffect(() => {
    reduced.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    if (reduced.current) {
      setLocked(chars.length - 1);
      return;
    }
    setLocked(-1);
    let raf = 0;
    let frame = 0;
    const start = performance.now();
    const tick = (now: number) => {
      const elapsed = now - start;
      const lockedCount = Math.min(
        chars.length - 1,
        Math.floor((elapsed - delay) / stagger)
      );
      setLocked(lockedCount);
      // churn unresolved glyphs every 3rd frame so they flicker, not strobe
      if (frame++ % 3 === 0) {
        setChurn(chars.map((c, i) => (i > lockedCount && c !== " " ? randomGlyph() : c)));
      }
      if (lockedCount < chars.length - 1) raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [text, delay, stagger, replayKey]);

  // idle life: every `ambient` ms a few adjacent characters glitch and relock
  useEffect(() => {
    if (!ambient || reduced.current) return;
    let churnId: ReturnType<typeof setInterval> | undefined;
    const id = setInterval(() => {
      const start = Math.floor(Math.random() * Math.max(1, chars.length - 3));
      const span = new Set(
        [start, start + 1, start + 2].filter((i) => i < chars.length && chars[i] !== " ")
      );
      setGlitch(span);
      let n = 0;
      churnId = setInterval(() => {
        setChurn((prev) =>
          prev.map((c, i) => (span.has(i) ? randomGlyph() : c))
        );
        if (++n > 6) {
          clearInterval(churnId);
          setGlitch(new Set());
        }
      }, 55);
    }, ambient);
    return () => {
      clearInterval(id);
      if (churnId) clearInterval(churnId);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [ambient, text, replayKey]);

  return (
    <span aria-label={text} role="text" className={`font-mono ${className}`}>
      {chars.map((c, i) => {
        const state =
          i <= locked && !glitch.has(i)
            ? "locked"
            : i === locked + 1 || glitch.has(i)
              ? "resolving"
              : "churning";
        return (
          <span
            key={i}
            aria-hidden
            className={
              state === "locked"
                ? "text-foreground"
                : state === "resolving"
                  ? "text-foreground brightness-150"
                  : "text-muted/70"
            }
          >
            {c === " " ? " " : state === "locked" ? c : churn[i] || c}
          </span>
        );
      })}
    </span>
  );
}
Use when

the classic scramble-to-decode reveal — monospace glyphs churn and lock into the final string left to right; use for a one-shot on-mount/on-trigger reveal, not a persistent cursor-driven effect.

Build spec

A scramble-to-decode text reveal: monospace glyphs churn randomly and lock into the final string left to right with a per-character stagger, the currently resolving character brightened, unresolved characters dimmed. No layout jitter (mono charset), final text exposed via aria-label with churning spans aria-hidden, instant render under prefers-reduced-motion.

Tags
textrevealmonomicro-interaction