Workspace / component / text

Decrypted Text

Reveals text through a cipher sweep: every glyph starts masked and decodes in a randomized order. A restrained take on the terminal trope.

Workspacecomponenttext

paxfx add blocks/component-text-decryptedlocal only

Tier: low. Reduced motion: Plain text renders immediately.

"use client";

import { useEffect, useMemo, useState } from "react";
import { cn, useInView, useReducedMotion } from "@pax-sh/blocks";

export interface DecryptedTextProps {
  text?: string;
  /** Heading level or inline element to render. */
  as?: "h1" | "h2" | "h3" | "p" | "span";
  /** Seconds for the full decode sweep. */
  duration?: number;
  /** Glyph pool used for the cipher mask. */
  glyphs?: string;
  className?: string;
}

const DEFAULT_GLYPHS = "!<>-_\\/[]{}~=+*^?#01";

function mulberry32(seed: number) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

/**
 * Reveals text through a cipher sweep: every glyph starts masked and
 * decodes in a randomized order drawn from a seeded Fisher-Yates shuffle,
 * so the sweep is identical on every run. Undecoded glyphs keep churning
 * deterministically until their slot unlocks.
 */
export function DecryptedText({
  text = "Access granted.",
  as: Tag = "h2",
  duration = 1.4,
  glyphs = DEFAULT_GLYPHS,
  className,
}: DecryptedTextProps) {
  const reduced = useReducedMotion();
  const [ref, inView] = useInView<HTMLSpanElement>({ threshold: 0.4 });

  /** Rank of each character in the reveal order, from a seeded shuffle. */
  const revealRank = useMemo(() => {
    const chars = Array.from(text);
    const order = chars.map((_, i) => i);
    const rand = mulberry32(0x5150c0de);
    for (let i = order.length - 1; i > 0; i -= 1) {
      const j = Math.floor(rand() * (i + 1));
      const a = order[i] as number;
      order[i] = order[j] as number;
      order[j] = a;
    }
    const rank = new Array<number>(chars.length).fill(0);
    order.forEach((charIdx, r) => {
      rank[charIdx] = r;
    });
    return rank;
  }, [text]);

  const masked = useMemo(
    () =>
      Array.from(text)
        .map((char, i) => (char === " " ? " " : glyphs[(i * 5 + 3) % glyphs.length]))
        .join(""),
    [text, glyphs],
  );

  const [display, setDisplay] = useState(masked);

  useEffect(() => {
    if (reduced) {
      setDisplay(text);
      return;
    }
    if (!inView) {
      setDisplay(masked);
      return;
    }

    const chars = Array.from(text);
    const span = Math.max(chars.length - 1, 1);
    const totalMs = duration * 1000;
    let raf = 0;
    const start = performance.now();

    const step = (now: number) => {
      const elapsed = now - start;
      if (elapsed >= totalMs) {
        setDisplay(text);
        return;
      }
      raf = requestAnimationFrame(step);
      const tick = Math.floor(elapsed / 46);
      const next = chars
        .map((char, i) => {
          if (char === " ") return " ";
          const revealAt = ((revealRank[i] ?? 0) / span) * totalMs * 0.82;
          if (elapsed >= revealAt) return char;
          return glyphs[(i * 11 + tick * 5) % glyphs.length];
        })
        .join("");
      setDisplay(next);
    };

    raf = requestAnimationFrame(step);
    return () => cancelAnimationFrame(raf);
  }, [inView, reduced, text, duration, glyphs, masked, revealRank]);

  return (
    <Tag
      className={cn("m-0 font-semibold tracking-tight", className)}
      data-pax-asset="component-text-decrypted"
    >
      <span className="sr-only">{text}</span>
      <span ref={ref} aria-hidden="true" className="inline-grid">
        <span className="invisible col-start-1 row-start-1 whitespace-pre-wrap">{text}</span>
        <span className="col-start-1 row-start-1 whitespace-pre-wrap">{display}</span>
      </span>
    </Tag>
  );
}