Skip to content
goodtabs UI
BrowseBrowse components

Scramble text

Characters scramble then settle in a wave, every width locked so nothing reflows.

Installs gsap from npm, under GSAP's own licence. No GSAP code ships in this registry.

npx shadcn@latest add https://ui.goodtabs.co/r/scramble-text.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/scramble-text

A mono eyebrow and heading that run once on entering view.

On hover

Links that scramble on hover and on keyboard focus, in a list that never shifts.

Usage

import { ScrambleText } from "@/components/ui/scramble-text"

<ScrambleText as="h2" className="font-mono text-5xl">Every width is locked</ScrambleText>
<ScrambleText trigger="hover" className="font-mono text-sm">Changelog</ScrambleText>

Props

PropTypeDefaultDescription
childrenstringThe text, required and a plain string.
as"span" | "p" | "h1" | "h2" | "h3""span"The element to render.
trigger"view" | "hover""view"Run once on entering view, or on every hover and keyboard focus.
charsstring"abcdefghijklmnopqrstuvwxyz0123456789/_-+="Glyphs to scramble through.
holdnumber0.5Seconds each character spends scrambling.

Source

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

import * as React from "react";
import { useGSAP } from "@gsap/react";
import gsap from "gsap";
import { ScrambleTextPlugin } from "gsap/ScrambleTextPlugin";
import { SplitText } from "gsap/SplitText";

import { cn } from "@/lib/utils";

gsap.registerPlugin(useGSAP, ScrambleTextPlugin, SplitText);

const MOTION = "(prefers-reduced-motion: no-preference)";
const POINTER = "(hover: hover) and (pointer: fine)";

type ScrambleTextProps = Omit<React.ComponentProps<"span">, "children"> & {
  /** The text. It stays a plain string so screen readers get one clean sentence. */
  children: string;
  /** The element to render. */
  as?: "span" | "p" | "h1" | "h2" | "h3";
  /** Run once on entering view, or on every hover and keyboard focus. */
  trigger?: "view" | "hover";
  /** Glyphs to scramble through. Keep them to one width class, and leave out `<` and `&`. */
  chars?: string;
  /** Seconds each character spends scrambling. */
  hold?: number;
};

/**
 * Characters scramble then settle in a wave from the left. Each character is locked to its own settled
 * width first, so nothing reflows. A character that has not been reached still shows its real glyph,
 * which makes the first frame the same as the server's HTML: there is no flash on hydration.
 */
function ScrambleText({
  as = "span",
  trigger = "view",
  chars = "abcdefghijklmnopqrstuvwxyz0123456789/_-+=",
  hold = 0.5,
  className,
  children,
  ...props
}: ScrambleTextProps) {
  const Tag = as as React.ElementType;
  const root = React.useRef<HTMLElement>(null);

  useGSAP(
    () => {
      const media = gsap.matchMedia();
      media.add(MOTION, (_, contextSafe) => {
        const element = root.current;
        const glyphs = element?.querySelector<HTMLElement>("[data-slot=scramble-text-glyphs]");
        if (!element || !glyphs || !contextSafe) return;
        let alive = true;
        let running = false;

        const play = contextSafe(() => {
          if (!alive || running) return;
          running = true;
          const split = SplitText.create(glyphs, { type: "words,chars", tag: "span", aria: "none" });
          const cells = split.chars as HTMLElement[];
          // Span wrappers are inline and ignore width, so words and cells become inline blocks first.
          gsap.set([...split.words, ...cells], { display: "inline-block" });
          const widths = cells.map((cell) => cell.getBoundingClientRect().width);
          cells.forEach((cell, i) => {
            cell.style.width = `${widths[i]}px`;
            cell.style.textAlign = "center";
          });
          const step = Math.min(0.03, 0.9 / cells.length);
          const timeline = gsap.timeline({
            onComplete: () => {
              split.revert();
              running = false;
            },
          });
          cells.forEach((cell, i) => {
            timeline.fromTo(
              cell,
              { opacity: 0.45 },
              {
                opacity: 1,
                duration: hold,
                ease: "power2.in",
                immediateRender: false,
                scrambleText: {
                  text: cell.textContent ?? "",
                  chars,
                  speed: 0.7,
                  revealDelay: hold,
                  tweenLength: false,
                },
              },
              i * step,
            );
          });
        }) as () => void;

        const cleanups: Array<() => void> = [];
        if (trigger === "view") {
          const watcher = new IntersectionObserver(
            (entries) => {
              if (!entries.some((e) => e.isIntersecting)) return;
              watcher.disconnect();
              void document.fonts.ready.then(play);
            },
            { threshold: 0.1 },
          );
          watcher.observe(element);
          cleanups.push(() => watcher.disconnect());
        } else {
          // A scramble inside a link or button answers to the whole control, keyboard included.
          const target = element.closest<HTMLElement>("a, button") ?? element;
          const onFocus = (e: FocusEvent) => {
            if ((e.target as HTMLElement).matches(":focus-visible")) play();
          };
          target.addEventListener("focusin", onFocus);
          cleanups.push(() => target.removeEventListener("focusin", onFocus));
          if (window.matchMedia(POINTER).matches) {
            target.addEventListener("pointerenter", play);
            cleanups.push(() => target.removeEventListener("pointerenter", play));
          }
        }
        return () => {
          alive = false;
          cleanups.forEach((clean) => clean());
        };
      });
      return () => media.revert();
    },
    { scope: root, dependencies: [children, trigger, chars, hold] },
  );

  return (
    <Tag ref={root} data-slot="scramble-text" className={cn("m-0", className)} {...props}>
      <span className="sr-only select-none">{children}</span>
      <span aria-hidden="true" data-slot="scramble-text-glyphs">
        {children}
      </span>
    </Tag>
  );
}

export { ScrambleText };

For AI agents

Hand your agent this page, the markdown at /components/scramble-text/prompt.md, or the Copy AI prompt button above.

When to use

  • Mono labels, version strings and short technical headings
  • Links in a dense list, as a small reward on hover
  • A status line that should feel like it just resolved

When not to use

  • Long paragraphs: it has to be read twice
  • Anything people must read while it runs, such as an error
  • Hero copy in a serif or display face, where the glyphs lose their shape

Agent notes

  • GSAP installs from npm under GSAP's own licence. No GSAP code ships in this registry
  • Mono reads best, but each character is locked to its settled width, so a proportional font does not reflow either
  • A character the wave has not reached shows its real glyph, so the first frame equals the server HTML and there is no flash
  • Inside a link or button the hover and focus listen on the whole control. Screen readers get the sr-only copy, the moving glyphs are aria-hidden
  • chars is written as HTML by the plugin, so leave out < and &. Children must be a string

The maths

  • Step is min(0.03, 0.9 / n) seconds, so 30 characters take 0.87 plus the 0.5 hold, 1.37 seconds.
  • Glyphs change every 0.05 / 0.7 = 71ms, 14 a second, because 60 a second reads as noise.
  • Every cell's width is measured before it scrambles, so the line's width is the sum of settled widths on every frame.