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…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

const DEFAULT_CHARSET = "!<>-_\\/[]{}=+*^?#%$&"

// render has to stay pure (react-hooks/purity): a deterministic hash of (step, i) replaces
// Math.random — step changes every frame, the scramble looks the same, and SSR/hydration agree
function pickChar(pool: string, step: number, i: number) {
  return pool[(Math.imul(step * 31 + i * 17 + 7, 2654435761) >>> 0) % pool.length]
}

export type ScrambleTextTrigger = "mount" | "view" | "hover"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/scramble-text.json

Prompt

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 than Math.random(), keeping render pure, hydration stable, and every frame reproducible.
  • Observe once, then unobserve — the view trigger 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 view and hover the 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.

On This Page