Text Effects

Text Shuffle

A scramble-decode effect where random glyphs resolve left-to-right into the final string.

Install

npx shadcn@latest add @paragon/text-shuffle

text-shuffle.tsx

"use client";

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

const DEFAULT_GLYPHS =
  "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";

/** 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 TextShuffleProps
  extends Omit<React.ComponentProps<"span">, "children"> {
  /** The final string the scramble resolves into. */
  children: string;
  /** Milliseconds before decoding starts. */
  delay?: number;
  /** Milliseconds between each character locking in, left to right. */
  stagger?: number;
  /** Milliseconds between glyph swaps on still-scrambled characters. */
  speed?: number;
  /** Glyph pool the scramble draws from. */
  glyphs?: string;
  /** Seed for the deterministic scramble. Defaults to a hash of useId + text. */
  seed?: number;
  /** Decode once and stay resolved, or rescramble and replay on re-entry. */
  once?: boolean;
  /** Render the final text plainly with no motion. */
  static?: boolean;
}

/**
 * A scramble-decode effect: random glyphs churn in place and resolve
 * left-to-right into the final string when scrolled into view.
 *
 * Each character sits in a cell sized by an invisible copy of its final
 * glyph, so the line never reflows mid-decode — in any font, monospace or
 * not. The scramble is seeded (useId or `seed` prop), never Math.random
 * during render, so server and client paint the same first frame.
 *
 * Inline element: wrap it in your own heading or paragraph. The full string
 * stays available to screen readers; the churning glyphs are decorative.
 * Reduced motion (or `static`) renders the final text immediately.
 */
export function TextShuffle({
  children,
  delay = 0,
  stagger = 30,
  speed = 40,
  glyphs = DEFAULT_GLYPHS,
  seed,
  once = true,
  static: isStatic = false,
  className,
  ...props
}: TextShuffleProps) {
  const id = React.useId();
  const ref = React.useRef<HTMLSpanElement>(null);
  const inView = useInView(ref, { once, amount: 0.4 });
  const reducedMotion = useReducedMotion() ?? false;

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

  // Deterministic first frame: the same seed produces the same scramble on
  // the server and the client, so hydration never mismatches.
  const initialScramble = React.useMemo(() => {
    const random = mulberry32(seedBase);
    return chars.map((ch) =>
      /\s/.test(ch) ? ch : glyphs[Math.floor(random() * glyphs.length)],
    );
  }, [chars, glyphs, seedBase]);

  const [state, setState] = React.useState<{
    display: string[];
    locked: number;
  }>({ display: initialScramble, locked: 0 });

  // Reset during render when the text/seed/glyphs change (memo identity).
  const [prevScramble, setPrevScramble] = React.useState(initialScramble);
  if (prevScramble !== initialScramble) {
    setPrevScramble(initialScramble);
    setState({ display: initialScramble, locked: 0 });
  }

  React.useEffect(() => {
    if (isStatic) return;
    if (!inView) {
      // once={false}: rescramble while offscreen so re-entry replays.
      if (!once) setState({ display: initialScramble, locked: 0 });
      return;
    }
    if (reducedMotion) {
      setState({ display: chars, locked: chars.length });
      return;
    }

    // Client-only tick randomness, still seeded — decorrelated from the
    // first frame so the decode doesn't open on a repeat.
    const random = mulberry32(seedBase ^ 0x9e3779b9);
    const lockStep = Math.max(1, stagger);
    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) {
        const locked = Math.min(chars.length, Math.floor(elapsed / lockStep));
        if (locked >= chars.length) {
          setState({ display: chars, locked: chars.length });
          return;
        }
        if (now - lastSwap >= speed) {
          lastSwap = now;
          setState({
            display: chars.map((ch, i) =>
              i < locked || /\s/.test(ch)
                ? ch
                : glyphs[Math.floor(random() * glyphs.length)],
            ),
            locked,
          });
        }
      }
      raf = requestAnimationFrame(tick);
    };

    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [
    inView,
    reducedMotion,
    isStatic,
    once,
    chars,
    glyphs,
    seedBase,
    delay,
    stagger,
    speed,
    initialScramble,
  ]);

  if (isStatic) {
    return (
      <span className={className} {...props}>
        {children}
      </span>
    );
  }

  return (
    <span ref={ref} data-slot="text-shuffle" className={className} {...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">
              {/* Invisible final glyph reserves its exact width, so the
                  overlay swap never reflows the line — in any font. */}
              <span className="invisible">{ch}</span>
              <span
                className={cn(
                  "absolute inset-0 text-center",
                  state.locked <= i && "text-muted-foreground",
                )}
                style={{
                  transitionProperty: "color",
                  transitionDuration: "var(--duration-quick)",
                  transitionTimingFunction: "var(--ease-out)",
                }}
              >
                {state.display[i]}
              </span>
            </span>
          ),
        )}
      </span>
    </span>
  );
}