ScrambleText

A headline that decodes into place without moving the page. The settled text holds the box while the noise is painted over it and clipped, so the line can never rewrap mid-effect.

Install

$ npx shadcn@latest add wisp.pouriah.com/r/scramble-text.json
Type · JSzero CLS
Ambient motionthat knows where your content isthe line never rewraps

ScrambleTextesc or double-click to exit full screendouble-tap to exit

primitives/scramble-text.tsx
"use client";

import * as React from "react";
import { cx, useMotionRateRef, useMotionTrigger } from "@pouriahlabs/wisp-ui";

/**
 * ScrambleText — a headline that decodes into place without moving the page.
 *
 * Every scramble effect reflows while it runs, because random glyphs are not
 * the width of the glyphs they stand in for: the line rewraps, and whatever
 * sits below it walks up and down for the whole animation. On a hero that is a
 * layout shift on first paint, which is the one thing a decorative effect must
 * never cause.
 *
 * So the settled text is always in the layout, holding the exact box it will
 * occupy when finished; the scrambling copy is painted over it, absolutely
 * positioned and clipped. The box is correct from the first frame to the last,
 * whatever noise is passing through it.
 *
 * The real string is the accessible name throughout — assistive tech is never
 * handed a line of gibberish, and the text is selectable once settled.
 */

export interface ScrambleTextProps extends React.HTMLAttributes<HTMLSpanElement> {
  /** The settled text. Also the accessible name while scrambling. */
  text: string;
  /** Glyphs drawn from while a character is still unresolved. */
  charset?: string;
  /** Milliseconds from first frame to fully settled. */
  duration?: number;
  /** Wait this long after entering view before starting, ms. */
  delay?: number;
  /** Replay every time it re-enters the viewport. */
  replay?: boolean;
  as?: React.ElementType;
}

const DEFAULT_CHARSET =
  "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789/\\|<>[]{}=+*·";

export function ScrambleText({
  text,
  charset = DEFAULT_CHARSET,
  duration = 1100,
  delay = 0,
  replay = false,
  as: Tag = "span",
  className,
  ...props
}: ScrambleTextProps) {
  const [display, setDisplay] = React.useState<string | null>(null);
  const raf = React.useRef(0);
  // Speed scales how fast the front sweeps; duration stretches the decode. Live,
  // so the sliders retime a scramble already running.
  const rateRef = useMotionRateRef();
  // Seeded explicitly — `useRef<T>()` with no argument is a type error under
  // `@types/react` 19, and this file gets copied into consumers' repos.
  const timer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);

  const ref = useMotionTrigger<HTMLElement>(
    {
      replay,
      rootMargin: "0px 0px -8% 0px",
      run: (done) => {
        let last = 0;
        let elapsed = 0;
        const frame = (now: number) => {
          elapsed += (last ? now - last : 0) * rateRef.current.speed;
          last = now;
          const progress = Math.min(1, elapsed / (duration * (rateRef.current.duration || 1)));

          // Resolve left to right. Characters just ahead of the front still
          // churn; everything behind it is final, so the line reads as it lands.
          const settledUpTo = progress * text.length;
          let out = "";
          for (let i = 0; i < text.length; i++) {
            const char = text[i]!;
            // Whitespace never scrambles — it is what keeps word shapes
            // legible while the letters are still noise.
            if (i < settledUpTo || char === " " || char === " ") {
              out += char;
            } else {
              out += charset[Math.floor(Math.random() * charset.length)];
            }
          }

          setDisplay(out);
          if (progress < 1) raf.current = requestAnimationFrame(frame);
          else {
            setDisplay(null);
            done();
          }
        };
        timer.current = setTimeout(() => {
          raf.current = requestAnimationFrame(frame);
        }, delay);
      },
      settle: () => {
        cancelAnimationFrame(raf.current);
        if (timer.current) clearTimeout(timer.current);
        setDisplay(null);
      },
      cancel: () => {
        cancelAnimationFrame(raf.current);
        if (timer.current) clearTimeout(timer.current);
      },
    },
    [text, charset, duration, delay, replay],
  );

  const scrambling = display !== null;

  return (
    <Tag
      ref={ref}
      aria-label={text}
      className={cx("wisp-scramble", className)}
      {...props}
    >
      {/* Always in the layout: this is what fixes the box. Hidden rather than
          removed while scrambling, so it keeps occupying exactly its own space. */}
      <span aria-hidden={scrambling} style={scrambling ? { visibility: "hidden" } : undefined}>
        {text}
      </span>

      {scrambling ? (
        <span aria-hidden className="wisp-scramble-overlay">
          {display}
        </span>
      ) : null}
    </Tag>
  );
}

Certified against the contract