Letter Scramble
Every character flips through a handful of wrong glyphs and then lands on the right one, each character starting a tenth of a second after the one before it, so the resolution sweeps left to right across the word. Each successive flip is held slightly longer than the last, which is what makes it read as settling rather than as a spinner that was switched off. Character widths are frozen to their final glyph before the first substitution, so the word does not breathe while it scrambles.
Letter Scramble
Live preview
The real component, running in this page.
Install
Copies the source into your project. Nothing is imported from us at runtime.
npx shadcn@latest add https://kinetic-seven-taupe.vercel.app/r/morphsvg-letter-scramble.jsonPrompt
The whole technique, written for an agent.
Build it with your agent
A single self-contained brief: the technique, the constants and why they hold, the pitfalls, and the definition of done. Paste it and the component gets written in your own conventions instead of copied in ours.
Source
4 files, exactly as installed.
API
Every parameter, its type and what happens when you change it.
| Parameter | Type | Default | Description |
|---|---|---|---|
| text | string | — | The resolved word. Rendered as-is for SSR, reduced motion, and assistive technology. |
| glyphs | string | = 'A-Z0-9#%&@$*' | Substitution pool. Keep to glyphs of similar visual weight. |
| flips | number | = 7 | Wrong glyphs shown per character before it resolves. |
| stagger | number | = 0.1 | Seconds between adjacent characters starting. |
| baseFlip | number | = 0.08 | Seconds the first wrong glyph is held. |
| flipGrowth | number | = 0.025 | Seconds added to each successive hold. This is the deceleration. |
| settle | number | = 0.45 | Seconds of the fade that lands with the final glyph. |
| replayOnHover | boolean | = true | Re-run on pointer enter, on fine pointers only. |
| startOnView | boolean | = true | Run when scrolled into view rather than on mount. |
| seed | number | string | — | Any string or number. The same seed gives the same sequence on every render. |
| className | string | — | Merged onto the outer span via cn. |
How it works
The technique in order, not the code in order.
The word is split in JSX to one
inline-blockspan per character, each carryingdata-finalwith its resolved glyph. React owns every node; nothing is injected into the DOM.A single
gsap.timelineschedulestl.call()at absolute times. For characterithe start time isSTART_DELAY + i * stagger; for flipfthe time advances bybaseFlip + f * flipGrowth, so hold durations grow 0.08, 0.105, 0.13, 0.155 … That arithmetic progression is the deceleration.Each
callassignsc.textContent = randomGlyph. After the last flip a finalcallwritesc.dataset.final, and afromTo(c, { opacity: DIM }, { opacity: 1, duration: settle, ease: "power3.out" })runs at the same time — the fade is what separates "resolved" from "one more wrong glyph".Glyphs come from a seeded PRNG (
createRng(seed ?? text + ":" + run)andrandInt), neverMath.random. The glyph sequence decides what is painted on the first client frame, and a server/client disagreement about that is a hydration mismatch.Before the timeline is built, every character box is pinned with
c.style.width = getBoundingClientRect().width + "px", measured from the *final* glyph that is currently in the DOM. Proportional type givesiandWwildly different advance widths, so an unpinned word visibly breathes and every element after it on the line shudders in sympathy. A finaltl.callat position">"clears the widths so the word reflows normally afterwards.The whole build is deferred behind
document.fonts.ready, because measuring before the webfont lands pins every box to a fallback-font width.The real word is rendered once in an
sr-onlyspan and the animated characters arearia-hidden, since mid-flight they are literal garbage.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| GLYPHS | = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#%&@$*' | The substitution pool. Uppercase and figures keep the visual weight even; adding lowercase mixes x-height and cap-height so the word appears to jitter vertically as well as horizontally. The punctuation adds texture without introducing very narrow glyphs. |
| FLIPS | = 7 | Wrong glyphs per character before resolving. Below ~4 there is no sense of searching; past ~10 the tail characters of a long word are still churning after the reader has looked away. |
| STAGGER | = 0.1 | Seconds between adjacent characters starting. Roughly the flip interval, so the sweep front is a couple of characters wide. Much smaller and the whole word scrambles as a block, losing the left-to-right read. |
| BASE_FLIP | = 0.08 | Seconds the first wrong glyph is held. About five frames at 60Hz — fast enough to be a blur, slow enough that the substitution is not skipped entirely on a slow frame. |
| FLIP_GROWTH | = 0.025 | Seconds added to each successive hold. This is the deceleration and it is the whole character of the effect: at 0 the flipping is constant-interval and reads as a slot machine that stopped, rather than as a search converging. |
| SETTLE | = 0.45 | Seconds of the opacity fade landing with the final glyph. Long enough to be a distinct beat after 0.08s flips; a shorter fade is indistinguishable from another substitution. |
| DIM | = 0.35 | Opacity the settle fade starts from. Fading from 0 makes the character disappear for a frame between the last wrong glyph and the right one, which reads as a dropped frame. |
| START_DELAY | = 0.25 | Seconds before the first character begins. Gives the page a moment to settle, so the scramble is not competing with layout for the reader's attention. |
| VIEW_THRESHOLD | = 0.4 | Fraction visible before the timeline runs, so a headline low on the page has actually arrived before it scrambles. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
The effect returns before building anything, so the word stays exactly as React rendered it — the resolved text, at full opacity, on first paint. Hover replay is also disabled. Nothing is scrambled, hidden, or left mid-substitution.
A cancelled flag guards the async document.fonts.ready boundary so a build cannot start after unmount. Teardown kills the timeline and then restores every character: textContent back to data-final, and style.width and style.opacity cleared. Without that restore, unmounting mid-scramble would leave the word frozen as garbage inside pinned boxes.
On entering the viewport each character flips through several wrong glyphs and lands on the right one, left to right. Time the flips: the last is visibly slower than the first. The word occupies exactly the same width throughout, and nothing on the same line moves. Reload repeatedly with a fixed seed and the identical glyph sequence appears every time, with no hydration warning in the console. Unmount mid-scramble and remount, and the word is correct. With reduced motion enabled the resolved word is present on first paint.
Pitfalls
- Using
Math.random()for the glyph sequence. The server and the client pick different glyphs, React reports a hydration mismatch, and the first frame flickers as the tree is patched. - Not freezing the character widths. Every substitution changes the advance width, so the word pulses and everything after it on the line jitters for the whole animation.
- Measuring those widths before
document.fonts.ready. Each box is then pinned to a fallback-font width, and when the real font arrives the glyphs are clipped or swimming inside boxes that are visibly the wrong size — the same failure mode described infont-safe-text-split. - Measuring the width of a substituted glyph rather than the final one. The box is locked to the wrong character forever.
- Never releasing the frozen widths. The word cannot reflow on a viewport change or a late font swap, so it breaks out of its container at narrow widths.
- Constant-interval flipping. Without the growing hold the effect reads as a slot machine that was switched off rather than as a search that converged.
- Leaving the scrambling characters in the accessibility tree. A screen reader announces the random glyphs as they change; the real word must be exposed separately and the animated spans hidden.
- Re-running the effect from a
Math.random-seeded value or an inline callback prop. The dependency array must contain only stable values or the scramble restarts on every parent render.
Notes
- The original technique used GSAP MorphSVGPlugin to morph hand-drawn letter paths into random variants. MorphSVG is a paid GSAP Club plugin, so this is the dependency-free equivalent: character substitution from a glyph set on a per-character timeline, preserving the two things that actually make the original read the way it does — the 0.1s stagger and the
0.08 + n * 0.025deceleration. - No text-splitting library either. The split is declarative JSX;
SplitTextis reserved for the components whose subject is SplitText itself. - Hover replay is gated on
(pointer: fine)and on reduced motion being off — on touch there is no hover, so it would only fire on press and read as a glitch.