Text Effects

Scramble In Text

A decode-in reveal where block cursors resolve into 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;
}

export interface ScrambleInTextProps
  extends Omit<React.ComponentProps<"span">, "children"> {
  /** The final string the decode resolves into. */
  children: string;
  /** Milliseconds each character stays as a block cursor before decoding. */
  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 cursor blocks cycle 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;
}

/**
 * ScrambleInText — a decode-in reveal. On enter, every character starts as a
 * dim block-cursor glyph and a left-to-right wave resolves them into the real
 * letters one after another, each snapping from a blurred, muted block into a
 * crisp glyph. Distinct from a churning shuffle: this is a directional
 * *reveal* (undecoded characters read as terminal cursor blocks, not random
 * letters), tuned to feel like text streaming out of a decoder.
 *
 * 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 blocks are decorative. Cells reserve each final glyph's width so
 * the line never reflows. Reduced motion (or `static`) shows the final text.
 * Pauses offscreen; `view` fires once, `hover` replays.
 */
export function ScrambleInText({
  children,
  speed = 42,
  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: trigger === "view", amount: 0.4 });
  const reducedMotion = useReducedMotion() ?? false;
  const [hovered, setHovered] = React.useState(false);

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

  // -1 = not yet decoding, 0..1 = decoding progress unused; we track a lock
  // index per char via a resolved count + a churn glyph for unresolved ones.
  const initial = React.useMemo(() => {
    const random = mulberry32(seedBase);
    return chars.map((ch) =>
      /\s/.test(ch) ? ch : glyphs[Math.floor(random() * glyphs.length)],
    );
  }, [chars, glyphs, seedBase]);

  const [display, setDisplay] = React.useState<string[]>(initial);
  const [resolved, setResolved] = React.useState<boolean[]>(() =>
    chars.map(() => false),
  );

  // Reset on text change.
  const [prev, setPrev] = React.useState(initial);
  if (prev !== initial) {
    setPrev(initial);
    setDisplay(initial);
    setResolved(chars.map(() => false));
  }

  const animated = !isStatic && !reducedMotion;
  const active = trigger === "view" ? inView : hovered;

  React.useEffect(() => {
    if (!animated) return;
    if (!active) {
      if (trigger === "hover") {
        setDisplay(initial);
        setResolved(chars.map(() => false));
      }
      return;
    }

    const random = mulberry32(seedBase ^ 0x9e3779b9);
    let raf = 0;
    let start: number | null = null;
    let lastSwap = -Infinity;

    const tick = (now: number) => {
      if (start === null) start = now;
      const elapsed = now - start - delay;
      if (elapsed < 0) {
        raf = requestAnimationFrame(tick);
        return;
      }
      const nextResolved = chars.map((ch, i) => {
        if (/\s/.test(ch)) return true;
        // Each char resolves after its stagger offset + one dwell (speed).
        return elapsed >= i * stagger + speed;
      });

      let changed = false;
      let doneAll = true;
      const swap = now - lastSwap >= speed;
      if (swap) lastSwap = now;

      const nextDisplay = chars.map((ch, i) => {
        if (/\s/.test(ch)) return ch;
        if (nextResolved[i]) return ch;
        doneAll = false;
        // Only start churning once this char's stagger window opens.
        if (elapsed >= i * stagger && swap) {
          changed = true;
          return glyphs[Math.floor(random() * glyphs.length)];
        }
        return display[i];
      });

      setResolved((r) =>
        r.some((v, i) => v !== nextResolved[i]) ? nextResolved : r,
      );
      if (changed || swap) setDisplay(nextDisplay);

      if (doneAll) {
        setDisplay(chars);
        setResolved(chars.map(() => true));
        return;
      }
      raf = requestAnimationFrame(tick);
    };

    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [animated, active, trigger, chars, glyphs, 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={trigger === "hover" ? () => setHovered(true) : undefined}
      onPointerLeave={trigger === "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">
              <span className="invisible">{ch}</span>
              <span
                className={cn(
                  "absolute inset-0 text-center",
                  !resolved[i] && "text-muted-foreground/70",
                )}
                style={{
                  filter: resolved[i] ? "blur(0px)" : "blur(1.2px)",
                  transitionProperty: "filter, color",
                  transitionDuration: "var(--duration-quick)",
                  transitionTimingFunction: "var(--ease-out)",
                }}
              >
                {resolved[i] ? ch : display[i]}
              </span>
            </span>
          ),
        )}
      </span>
    </span>
  );
}