Workspace / component / text

Scramble Text

Characters churn through a glyph pool and lock into place left to right. Deterministic pool ordering keeps the motion tight instead of noisy.

Workspacecomponenttext

paxfx add blocks/component-text-scramblelocal only

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

"use client";

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

export interface ScrambleTextProps {
  text?: string;
  /** Heading level or inline element to render. */
  as?: "h1" | "h2" | "h3" | "p" | "span";
  /** Seconds for the full left-to-right lock-in. */
  duration?: number;
  /** Glyph pool characters churn through before locking. */
  glyphs?: string;
  className?: string;
}

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

/**
 * Characters churn through a glyph pool and lock into place left to right.
 * Pool indices derive from character position and a frame tick, so the
 * churn is fully deterministic and reads tight instead of noisy. One rAF
 * loop drives the whole run and cancels on unmount.
 */
export function ScrambleText({
  text = "Signal over noise.",
  as: Tag = "h2",
  duration = 1.3,
  glyphs = DEFAULT_GLYPHS,
  className,
}: ScrambleTextProps) {
  const reduced = useReducedMotion();
  const [ref, inView] = useInView<HTMLSpanElement>({ threshold: 0.4 });
  const [display, setDisplay] = useState(text);

  useEffect(() => {
    if (reduced || !inView) {
      setDisplay(text);
      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 / 42);
      const next = chars
        .map((char, i) => {
          if (char === " ") return " ";
          const lockAt = (i / span) * totalMs * 0.72;
          if (elapsed >= lockAt) return char;
          return glyphs[(i * 7 + tick * 13 + i * i) % glyphs.length];
        })
        .join("");
      setDisplay(next);
    };

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

  return (
    <Tag
      className={cn("m-0 font-semibold tracking-tight", className)}
      data-pax-asset="component-text-scramble"
    >
      <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>
  );
}