Scramble In Text
Text Effects

Scramble In Text

A decode-in reveal where characters churn through random glyphs and lock into the real letters left-to-right with a subtle deblur.

Install

npx shadcn@latest add @paragon/scramble-in-text

scramble-in-text.tsx

"use client";

import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

const DEFAULT_GLYPHS = "▚▞░▒▓█<>/\\=+*|";

/** Deterministic 32-bit PRNG (mulberry32) — identical on server and client. */
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;
  };
}

/** FNV-1a hash — turns useId + text into a stable numeric seed. */
function hashString(str: string) {
  let h = 2166136261;
  for (let i = 0; i < str.length; i++) {
    h ^= str.charCodeAt(i);
    h = Math.imul(h, 16777619);
  }
  return h >>> 0;
}

/** True on devices with a real hover pointer; SSR assumes fine and corrects after mount. */
function useFinePointer(): boolean {
  const subscribe = React.useCallback((onChange: () => void) => {
    const mql = window.matchMedia("(hover: hover) and (pointer: fine)");
    mql.addEventListener("change", onChange);
    return () => mql.removeEventListener("change", onChange);
  }, []);
  return React.useSyncExternalStore(
    subscribe,
    () => window.matchMedia("(hover: hover) and (pointer: fine)").matches,
    () => true,
  );
}

export interface ScrambleInTextProps
  extends Omit<React.ComponentProps<"span">, "children"> {
  /** The final string the decode resolves into. */
  children: string;
  /** Milliseconds each character churns before it locks to its final glyph. */
  speed?: number;
  /** Milliseconds between successive characters starting to decode (the wave). */
  stagger?: number;
  /** Milliseconds before the reveal begins. */
  delay?: number;
  /** Glyph pool the pre-decode characters churn through. */
  glyphs?: string;
  /** Seed for the deterministic scramble. Defaults to a hash of useId + text. */
  seed?: number;
  /** `view` — decode once on scroll-in. `hover` — replay on hover. */
  trigger?: "view" | "hover";
  /** Render the final text with no motion. */
  static?: boolean;
}

interface Frame {
  glyph: string[];
  resolved: boolean[];
}

/** Every ~55ms the churning glyphs advance, so undecoded characters visibly flicker. */
const CHURN_MS = 55;

/**
 * ScrambleInText — a decode-in reveal. On enter, every character churns through
 * random glyphs and a left-to-right wave locks them to the real letters one
 * after another, each snapping from a blurred, muted glyph into a crisp one.
 * The wave is strictly directional: character `i` starts churning at `i *
 * stagger` and resolves one dwell (`speed`) later, so the string always
 * resolves from the left.
 *
 * Seeded (useId or `seed`) so server and client paint the same first frame — no
 * Math.random during render. The full string stays available to screen readers;
 * the churning glyphs are decorative. Cells reserve each final glyph's width so
 * the line never reflows. Reduced motion (or `static`) shows the final text.
 * `view` fires once on scroll-in; `hover` replays on fine pointers and falls
 * back to `view` on touch so the text never sits undecoded.
 */
export function ScrambleInText({
  children,
  speed = 90,
  stagger = 55,
  delay = 0,
  glyphs = DEFAULT_GLYPHS,
  seed,
  trigger = "view",
  static: isStatic = false,
  className,
  ...props
}: ScrambleInTextProps) {
  const id = React.useId();
  const ref = React.useRef<HTMLSpanElement>(null);
  const inView = useInView(ref, { once: true, amount: 0.4 });
  const reducedMotion = useReducedMotion() ?? false;
  const finePointer = useFinePointer();
  const [hovered, setHovered] = React.useState(false);

  const chars = React.useMemo(() => Array.from(children), [children]);
  const glyphPool = React.useMemo(() => Array.from(glyphs), [glyphs]);
  const seedBase = seed ?? hashString(id + children);

  // First painted frame: every non-space character shows a seeded random glyph
  // (deterministic, so SSR and the client agree), nothing resolved yet.
  const initial = React.useMemo<Frame>(() => {
    const rand = mulberry32(seedBase);
    return {
      glyph: chars.map((ch) =>
        /\s/.test(ch)
          ? ch
          : glyphPool[Math.floor(rand() * glyphPool.length)] ?? ch,
      ),
      resolved: chars.map((ch) => /\s/.test(ch)),
    };
  }, [chars, glyphPool, seedBase]);

  const [frame, setFrame] = React.useState<Frame>(initial);

  // Reset during render when the text/glyphs/seed change (memo identity).
  const [prevInitial, setPrevInitial] = React.useState(initial);
  if (prevInitial !== initial) {
    setPrevInitial(initial);
    setFrame(initial);
  }

  const animated = !isStatic && !reducedMotion;
  // Hover is a fine-pointer interaction; on touch the decode runs on view so
  // the text never sits as undecoded glyphs waiting for an impossible hover.
  const effectiveTrigger =
    trigger === "hover" && !finePointer ? "view" : trigger;
  const active = effectiveTrigger === "view" ? inView : hovered;

  React.useEffect(() => {
    if (!animated) return;
    if (!active) {
      // Hover replay: re-scramble while idle so the next hover decodes fresh.
      if (effectiveTrigger === "hover") setFrame(initial);
      return;
    }

    const rand = mulberry32(seedBase ^ 0x9e3779b9);
    let raf = 0;
    let start = 0;
    let churnStep = -1;
    let lastCommittedStep = -1;
    let lastResolvedCount = -1;
    let churnGlyphs = initial.glyph.slice();

    const tick = (now: number) => {
      if (!start) start = now;
      const elapsed = now - start - delay;

      // Advance the churn pool on a fixed cadence so undecoded glyphs flicker.
      const step = elapsed <= 0 ? 0 : Math.floor(elapsed / CHURN_MS);
      if (step !== churnStep) {
        churnStep = step;
        churnGlyphs = chars.map((ch, i) =>
          /\s/.test(ch)
            ? ch
            : glyphPool[Math.floor(rand() * glyphPool.length)] ??
              churnGlyphs[i],
        );
      }

      const glyph = new Array<string>(chars.length);
      const resolved = new Array<boolean>(chars.length);
      let allDone = true;
      let resolvedCount = 0;
      for (let i = 0; i < chars.length; i++) {
        const ch = chars[i];
        if (/\s/.test(ch)) {
          glyph[i] = ch;
          resolved[i] = true;
          continue;
        }
        // Character i churns from `i*stagger` and locks one dwell later.
        const lockAt = i * stagger + speed;
        if (elapsed >= lockAt) {
          glyph[i] = ch;
          resolved[i] = true;
          resolvedCount++;
        } else {
          resolved[i] = false;
          allDone = false;
          // Before its window opens keep the seeded initial glyph; once open,
          // show the live churn glyph.
          glyph[i] = elapsed >= i * stagger ? churnGlyphs[i] : initial.glyph[i];
        }
      }

      // Commit only when the churn advanced, a character just resolved, or the
      // decode finished — avoids a render on frames where nothing changed.
      if (
        allDone ||
        step !== lastCommittedStep ||
        resolvedCount !== lastResolvedCount
      ) {
        lastCommittedStep = step;
        lastResolvedCount = resolvedCount;
        setFrame({ glyph, resolved });
      }
      if (allDone) return;
      raf = requestAnimationFrame(tick);
    };

    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [
    animated,
    active,
    effectiveTrigger,
    chars,
    glyphPool,
    seedBase,
    delay,
    speed,
    stagger,
    initial,
  ]);

  if (isStatic || reducedMotion) {
    return (
      <span ref={ref} data-slot="scramble-in-text" className={className} {...props}>
        {children}
      </span>
    );
  }

  return (
    <span
      ref={ref}
      data-slot="scramble-in-text"
      className={className}
      onPointerEnter={
        effectiveTrigger === "hover" ? () => setHovered(true) : undefined
      }
      onPointerLeave={
        effectiveTrigger === "hover" ? () => setHovered(false) : undefined
      }
      {...props}
    >
      <span className="sr-only">{children}</span>
      <span aria-hidden="true">
        {chars.map((ch, i) =>
          /\s/.test(ch) ? (
            <React.Fragment key={i}>{ch}</React.Fragment>
          ) : (
            <span key={i} className="relative inline-block">
              {/* Reserve the final glyph's width so the line never reflows. */}
              <span className="invisible">{ch}</span>
              <span
                className={cn(
                  "absolute inset-0 text-center tabular-nums",
                  !frame.resolved[i] && "text-muted-foreground",
                )}
                style={{
                  filter: frame.resolved[i] ? "blur(0px)" : "blur(1.4px)",
                  transitionProperty: "filter, color",
                  transitionDuration: "var(--duration-quick)",
                  transitionTimingFunction: "var(--ease-out)",
                }}
              >
                {frame.resolved[i] ? ch : frame.glyph[i]}
              </span>
            </span>
          ),
        )}
      </span>
    </span>
  );
}