Text scramble hover effect in React
A link whose characters decode from random glyphs into the real word on hover, resolving left to right.
Built 4 Aug 2026 · Last verified 4 Aug 2026
The short version
A text scramble replaces each character with a random glyph, then resolves them to the real text one at a time from the left. It runs on a single requestAnimationFrame loop that holds a per-character start and end frame, so the cost is one string rebuild per frame regardless of how long the text is. The element keeps its real text in the DOM for screen readers and search engines; only a visual layer scrambles.
How it works
- On hover the component builds a queue: one entry per character, each with the target glyph, a start frame and an end frame staggered across the animation.
- A requestAnimationFrame loop walks the queue each frame. Characters before their start frame render as a space, characters past their end frame render their real glyph, and characters in between render a random one from a fixed set.
- The random glyph is re-rolled only every few frames rather than every frame, which is what makes it read as flicker rather than static.
- The loop stops as soon as every character has resolved, so an idle link costs nothing. The frame handle is stored in a ref and cancelled on unmount and on pointerleave.
- The real text stays in the DOM as a visually-hidden span. The scrambling layer is aria-hidden, so assistive technology and crawlers only ever see the finished word.
- Width is reserved with a fixed inline-size derived from the resolved text, so the surrounding layout never shifts while characters are mid-scramble.
The version everyone ships is broken
Text scramble effects are everywhere, and most of them have the same defect: they scramble the real text. The component holds the label in state, replaces its characters with random glyphs, and writes the result back into the DOM.
That means the accessible name of the link changes sixty times a second while the animation runs. A screen reader on a live region announces gibberish. A crawler that samples the DOM mid-animation indexes gibberish. And if the JavaScript fails to load or throws before the effect settles, the label is whatever the last frame left behind.
The fix is small. Keep the real text in the DOM permanently, in a span that is
visually hidden but present, and put the scrambling glyphs in a second span
marked aria-hidden. Assistive technology and crawlers only ever see the
finished word. The animation becomes a purely visual layer sitting on top of
content that never changes.
The obvious way to hide that span is visibility: hidden, and it is a trap I
walked into while building this. Hidden text is removed from the accessible name
as well as from the page, and since the scrambling layer is already
aria-hidden, the result is a link with no accessible name whatsoever: strictly
worse than the bug I set out to fix.
The clipped-rectangle pattern is the correct one. position: absolute with a
clip-path: inset(50%) takes the span out of the layout while leaving it in the
accessibility tree, so the link is named and the name never changes.
That leaves the width to solve some other way, and the fix is in the loop rather than the stylesheet. Every frame emits exactly one character per slot, a space where the character has not started resolving yet, so the string is always the same length. In a monospace face that means the rendered width is constant from the first frame, and the surrounding line cannot reflow. Emit an empty string for the not-yet-started slots instead and you get a link that visibly grows as it resolves.
Why the queue, and not a timer per character
The naive implementation gives each character its own setTimeout chain. For a
fifteen-character label that is fifteen timers, all landing on their own
schedule, all rebuilding the same string.
Building a queue up front instead, one entry per character carrying a start
frame and an end frame, means a single requestAnimationFrame loop that rebuilds
the string once per frame. The cost is one pass over the queue regardless of how
long the text is, and it is bounded by the frame rate rather than by the number
of characters.
It also makes the stagger a number rather than a scheduling problem. Each
character starts i * 1.6 frames after the one before it, which is what makes
the label resolve left to right instead of all at once. Change the multiplier and
you change the feel: below one it reads as a flash, above three it drags.
Roll slower than you draw
The detail that separates a scramble that reads as decoding from one that reads as static is how often the random glyph changes.
Re-rolling every frame gives you sixty changes a second, which the eye integrates into a grey blur. Re-rolling every third frame gives you twenty, which is slow enough to register as individual characters flickering and fast enough to feel unstable. Twenty is roughly where film-era title sequences landed too, which is probably not a coincidence.
The glyph set matters as well. Mine is punctuation and brackets, with a run of underscores at the end so that a disproportionate share of the rolls land on a low, quiet character. Without that weighting the effect is too dense and reads as noise rather than as text resolving.
Reduced motion is a cut, not a slowdown
Most reduced-motion handling shortens durations. This one skips the animation entirely, and it should.
Rapidly changing glyphs are exactly the class of effect the setting exists for. For someone with a vestibular disorder or a sensitivity to flicker, a slower scramble is not a kindness, it is the same problem with more time to notice it. The check happens in the handler before the loop is ever scheduled, so under reduced motion this component does no per-frame work at all.
What replaces it is a colour transition on hover. The link still responds; it just responds calmly.
Cleanup, which is the part that bites
The loop stops on its own the moment every character has resolved, so an idle link costs nothing. But two other exits need handling and one of them is easy to miss.
Leaving with the pointer mid-animation cancels the frame and snaps the label
back, which is the obvious one. Unmounting mid-animation is the one that bites:
an in-flight frame holding a setState on a component that no longer exists is
the classic React leak, and on a page like this one, where a card can scroll out
of view while you are still hovering it, it is not hypothetical.
The frame handle lives in a ref and the effect returns the cancel function directly, so both paths run the same cleanup.
The source
MIT licensed. Use it in anything, no attribution needed.
Read straight off the file the demo above imports, then highlighted at build time. What you are reading is what is running.
Demo.tsxtsx
import { useCallback, useEffect, useRef, useState } from "react";
import styles from "./Demo.module.scss";
const GLYPHS = "!<>-_\\/[]{}=+*^?#________";
/** Frames a character spends scrambling before it resolves. */
const SPREAD = 22;
/** Re-roll the random glyph every N frames: any faster reads as static. */
const ROLL_EVERY = 3;
const REDUCED =
typeof window === "undefined"
? null
: window.matchMedia("(prefers-reduced-motion: reduce)");
type Slot = { char: string; start: number; end: number };
type Props = {
/** The label. It resolves to exactly this, and never leaves the DOM. */
text?: string;
href?: string;
};
export default function TextScrambleLink({
text = "Start a project",
href = "#lab-demo",
}: Props) {
const [display, setDisplay] = useState(text);
const frame = useRef(0);
const raf = useRef<number | null>(null);
const stop = useCallback(() => {
if (raf.current !== null) cancelAnimationFrame(raf.current);
raf.current = null;
}, []);
// Cancel on unmount as well as on pointer-out: an in-flight frame holding a
// setState on an unmounted component is the classic leak here.
useEffect(() => stop, [stop]);
const scramble = useCallback(() => {
// Rapidly changing glyphs are exactly what the reduced-motion setting is
// for, so this one is a hard cut rather than a shortened animation.
if (REDUCED?.matches) return;
stop();
frame.current = 0;
const slots: Slot[] = [...text].map((char, i) => ({
char,
start: Math.floor(i * 1.6),
end: Math.floor(i * 1.6) + SPREAD,
}));
const tick = () => {
const f = frame.current++;
let done = 0;
let out = "";
for (const slot of slots) {
if (f >= slot.end) {
out += slot.char;
done++;
} else if (f >= slot.start) {
// Re-roll on a slower clock than the frame rate.
const roll = Math.floor((f - slot.start) / ROLL_EVERY);
out += GLYPHS[(roll * 7 + slot.char.charCodeAt(0)) % GLYPHS.length];
} else {
// A space, never an empty string: every frame emits exactly one
// character per slot, so in a monospace face the rendered width is
// constant and the line cannot reflow mid-scramble.
out += " ";
}
}
setDisplay(out);
if (done === slots.length) {
raf.current = null;
return;
}
raf.current = requestAnimationFrame(tick);
};
raf.current = requestAnimationFrame(tick);
}, [stop, text]);
const settle = useCallback(() => {
stop();
setDisplay(text);
}, [stop, text]);
return (
<div className={styles.wrap}>
<a
href={href}
className={styles.link}
onPointerEnter={scramble}
onPointerLeave={settle}
onFocus={scramble}
onBlur={settle}
>
{/* The real text never leaves the DOM: it is what screen readers
announce and what crawlers index. It is clipped rather than
`visibility: hidden`, because hidden text is dropped from the
accessible name too, which would leave this link unnamed. */}
<span className={styles.sr}>{text}</span>
<span className={styles.visual} aria-hidden>
{display}
</span>
</a>
</div>
);
}Demo.module.scssscss
.wrap {
display: grid;
place-items: center;
padding: clamp(36px, 8vw, 64px);
}
.link {
position: relative;
display: inline-block;
font-family: var(--mono);
font-size: clamp(18px, 3vw, 24px);
letter-spacing: 0.02em;
color: var(--silver-050);
padding: 6px 2px;
border-bottom: 1px solid var(--line);
transition: color 0.25s ease, border-color 0.25s ease;
&:hover,
&:focus-visible {
color: var(--accent, var(--butter));
border-color: var(--accent, var(--butter));
}
}
/* The accessible name. Clipped, NOT `visibility: hidden`: hidden text is
excluded from the accessible name as well as from the page, which would
leave this link with no name at all. */
.sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
/* Sits in normal flow and reserves its own width. The component emits one
character per slot on every frame, so in a monospace face that width never
changes and the surrounding line cannot reflow. */
.visual {
white-space: pre;
}
@container stage (max-width: 360px) {
.wrap {
padding: 28px 12px;
}
.link {
font-size: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
/* The scramble itself is skipped in the component; this is the colour
transition that replaces it. */
.link {
transition: none;
}
}Questions worth answering
Does it work in Safari?
Every current browser. It is plain JavaScript and requestAnimationFrame, with no platform feature newer than 2015.
Does it need JavaScript?
Yes, and unavoidably so: the effect is a per-frame string rebuild. What matters is that the real text never leaves the DOM, so the JavaScript adds a visual layer rather than being the only way to read the link.
Is it accessible?
Reduced motion. The scramble is skipped entirely and the link renders its text with a colour transition instead. Rapidly changing glyphs are exactly the kind of effect the reduced-motion setting exists for, so this one is a hard cut rather than a shortened animation.
Keyboard. The effect fires on :focus-visible as well as hover, so a keyboard user sees the same thing. Because the accessible name comes from the visually-hidden real text, it never changes while the animation runs.
Want this kind of care on a build?
I build fast, production-grade sites for freelance clients. The polish is the same; there is just more of it.