Prism
← All primitives

ScrambleText

Motion

Decrypts its text from random glyphs to the target when it scrolls into view. rAF-stepped, zero deps. Use a monospace container to avoid reflow.

$npx shadcn@latest add https://prism.icglabs.co/r/scramble-text.json
View raw manifest →

components/prism/ScrambleText.tsx
"use client";

import { createElement, useEffect, useRef, useState, type ElementType } from "react";

/**
 * ScrambleText — decrypts its text from random glyphs to the target when it
 * scrolls into view. rAF-stepped, no deps. Use a monospace/tabular container to
 * avoid per-frame reflow. Honors prefers-reduced-motion (shows final text).
 */
const GLYPHS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#%&@$*<>/\\";

export function ScrambleText({
  text,
  className = "",
  duration = 1.1,
  as: Tag = "span",
}: {
  text: string;
  className?: string;
  /** seconds */
  duration?: number;
  as?: "span" | "h1" | "h2" | "h3" | "p";
}) {
  const ref = useRef<HTMLSpanElement>(null);
  const [display, setDisplay] = useState(text);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      setDisplay(text);
      return;
    }

    let raf = 0;
    let start = 0;
    const step = (now: number) => {
      if (!start) start = now;
      const p = Math.min((now - start) / (duration * 1000), 1);
      const revealed = Math.floor(p * text.length);
      let out = "";
      for (let i = 0; i < text.length; i++) {
        if (i < revealed || text[i] === " ") out += text[i];
        else out += GLYPHS[Math.floor(Math.random() * GLYPHS.length)];
      }
      setDisplay(out);
      if (p < 1) raf = requestAnimationFrame(step);
      else setDisplay(text);
    };

    const io = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting) {
          raf = requestAnimationFrame(step);
          io.disconnect();
        }
      },
      { threshold: 0.5 }
    );
    io.observe(el);

    return () => {
      cancelAnimationFrame(raf);
      io.disconnect();
    };
  }, [text, duration]);

  const Comp = Tag as ElementType;
  return createElement(
    Comp,
    { ref, className, style: { fontVariantNumeric: "tabular-nums" }, "aria-label": text },
    display
  );
}
Live demo — read-only. Every section is a real, copyable primitive.