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
| Prop | Type | Default | Description |
|---|---|---|---|
| children | string | The 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. |
| chars | string | "abcdefghijklmnopqrstuvwxyz0123456789/_-+=" | Glyphs to scramble through. |
| hold | number | 0.5 | Seconds 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.