Text
Scramble Text
Text that lands as random characters and decodes left to right, on mount, on scroll-in or on hover.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/scramble-text.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ScrambleText" component (no animation
library — one interval plus an IntersectionObserver).
Contract
- Export a forwardRef span extending React.HTMLAttributes<HTMLSpanElement>.
- Props: text (the final string), trigger = "mount" | "view" | "hover"
(default "mount"), speed (ms between scramble frames, default 40),
charset (pool the undecoded characters are drawn from, default a symbol
soup like "!<>-_\\/[]{}=+*^?#%$&"), revealDelay (ms between two characters
locking in, default 55), repeatOnHover (default true, only meaningful for
trigger="hover").
- className lands on the outer span; typography is inherited from the call
site so the component never sets size or color.
Behavior
- Two independent rates: a frame counter advances every `speed` ms, and the
number of settled characters is derived as floor(frames * speed /
revealDelay). Characters before that index render their real value;
everything after renders a pseudo-random glyph from charset. Left-to-right
decode falls out of the derivation — no per-character timers.
- Never call Math.random during render (it breaks purity and hydration).
Derive the glyph from a deterministic hash of (frame, characterIndex),
e.g. Math.imul-based, so the same frame always paints the same noise and
SSR output matches the client.
- Whitespace never scrambles: spaces, tabs and newlines render as
themselves at every frame, so word boundaries and line breaks stay put
while the letters churn.
- Trigger handling:
mount -> the run starts at first render (the initial frame IS the
scrambled state, so there is no plain-text flash);
view -> an IntersectionObserver (threshold ~0.4) starts the run the
first time the element intersects, then unobserves immediately
and disconnects on unmount — it decodes exactly once;
hover -> onMouseEnter starts a run; repeatOnHover lets every subsequent
enter restart it (otherwise only the first one does). Before any
trigger fires, the real text is what is on screen — nothing is
hidden from a user who never hovers.
- The interval only exists while a run is unfinished: when the settled
count reaches the length, the effect returns early and clears it. Restart
= bump a run counter and reset the frame counter (state is reset during
render when text/trigger change, no effect needed).
- prefers-reduced-motion (useSyncExternalStore over matchMedia, server
snapshot false): render the final text, never start a run, ignore hover.
Rendering & styling
- Semantic tokens only; no color of its own — it inherits currentColor so
muted / primary contexts and dark mode work unchanged.
- Width discipline: scrambling swaps glyphs of different widths, which makes
a proportional font jitter. The component sets tabular-nums (fixes digit
widths) and the recommended call site adds a mono font class for text —
that combination keeps the line completely still while it decodes.
- Accessibility: the churning glyphs are noise, so the animated span is
aria-hidden and the outer span carries aria-label={text}; assistive tech
reads the final string once. Use whitespace-pre-wrap on the animated span
so preserved spaces are not collapsed.
Customization levers
- Character pool: charset is the whole personality — symbols read as
"glitch", "0123456789" reads as a counter settling, uppercase A-Z reads
as a code being cracked.
- Pace: speed controls how fast the noise churns, revealDelay controls how
fast the message resolves; a long revealDelay with a short speed gives a
long, busy decode.
- Trigger: "view" for a section headline, "hover" for interactive labels
and links, "mount" for a hero that should decode on load.
- Font: pair with a mono class for a terminal look, or keep the ambient
font for a subtler effect (accept a little width jitter).
- Scope: wrap one word inside a sentence rather than the whole line — the
component is inline and inherits everything around it.Concepts
- Decode-on-reveal — the reveal is the animation: characters arrive as noise and lock in left to right, so the moment the eye lands on the line it is already resolving into meaning.
- Two rates, one timer — the churn rate (
speed) and the settle rate (revealDelay) are derived from a single frame counter, so there is exactly one interval no matter how long the string is. - Deterministic noise — the glyph for a character is a hash of
(frame, index)rather thanMath.random(), keeping render pure, hydration stable, and every frame reproducible. - Observe once, then unobserve — the
viewtrigger stops watching the element the instant it fires, so scrolling past repeatedly costs nothing and the component leaves no live observer behind. - Whitespace is structure, not decoration — spaces and newlines are excluded from the scramble so words keep their shape and the paragraph never reflows mid-decode.
- Readable before it is triggered — for
viewandhoverthe untouched text is what is rendered, so a user who never scrolls or hovers still gets the message, and search engines index the real string.