Decodes text in place: each character cycles through random glyphs before settling, left to right. Plays on mount, in view or on hover, keeps its final width, and exposes the final text to screen readers.
PRODUCTION IS LIVE
- Resolved 214 packages1.2s
- Built 38 routes8.4s
- Uploaded to 35 regions3.1s
- Health checks passed0.9s
import { Check } from "lucide-react"
import { ScrambleText } from "@/components/ballmac/scramble-text"
const steps = [
{ label: "Resolved 214 packages", meta: "1.2s" },
{ label: "Built 38 routes", meta: "8.4s" },
{ label: "Uploaded to 35 regions", meta: "3.1s" },
{ label: "Health checks passed", meta: "0.9s" },
]
export default function ScrambleTextDemo() {
return (
<div className="w-full max-w-md overflow-hidden rounded-xl border bg-card text-card-foreground shadow-xs">
<div className="flex items-center gap-2 border-b bg-muted/40 px-4 py-2.5">
<span className="size-2 rounded-full bg-chart-2" aria-hidden="true" />
<span className="font-mono text-xs text-muted-foreground">acme deploy --prod</span>
</div>
<div className="p-5">
<ScrambleText as="h3" mono duration={1100} className="text-lg font-semibold tracking-tight">
PRODUCTION IS LIVE
</ScrambleText>
<ul className="mt-4 space-y-2 font-mono text-[13px]">
{steps.map((step, i) => (
<li key={step.label} className="flex items-center gap-2.5">
<Check className="size-3.5 shrink-0 text-[color-mix(in_oklch,var(--chart-2),black_42%)] dark:text-chart-2" aria-hidden="true" />
<ScrambleText mono delay={250 + i * 220} duration={700} className="min-w-0 flex-1">
{step.label}
</ScrambleText>
<span className="text-muted-foreground tabular-nums">{step.meta}</span>
</li>
))}
</ul>
<div className="mt-4 flex items-center justify-between rounded-md border bg-background px-3 py-2 font-mono text-[13px]">
<ScrambleText mono delay={1200} duration={900} characters="0123456789abcdef">
https://acme.com
</ScrambleText>
<span className="text-xs text-[color-mix(in_oklch,var(--chart-2),black_42%)] dark:text-chart-2">200 OK</span>
</div>
</div>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/scramble-textInstall the dependencies.
$ pnpm add motion@^12Copy the source into your project.
components/ballmac/scramble-text.tsx// Ballmac UI: Scramble Text. https://ui.ballmac.com/components/scramble-text "use client" import * as React from "react" import { useInView } from "motion/react" import { cn } from "@/lib/utils" type ScrambleTextElement = "span" | "p" | "div" | "h1" | "h2" | "h3" | "h4" | "code" type ScrambleTextProps = Omit<React.ComponentProps<"span">, "children"> & { /** The final text. */ children: string /** The element to render. */ as?: ScrambleTextElement /** When to play: on mount, when scrolled into view (once), or on every hover and keyboard focus. */ trigger?: "mount" | "inView" | "hover" /** Milliseconds from the first scrambled frame until the last character settles. */ duration?: number /** Milliseconds to wait before starting. */ delay?: number /** Milliseconds between frames; lower values cycle glyphs faster. */ speed?: number /** Glyphs to cycle through before each character resolves. */ characters?: string /** Use a monospace font so the width never changes while scrambling. */ mono?: boolean /** Called after the text has fully resolved. */ onComplete?: () => void } const REDUCED_QUERY = "(prefers-reduced-motion: reduce)" function subscribeReducedMotion(onChange: () => void) { const media = window.matchMedia(REDUCED_QUERY) media.addEventListener("change", onChange) return () => media.removeEventListener("change", onChange) } /** Reduced-motion preference that is false on the server and during hydration, so markup always matches. */ function useReducedMotionSafe() { return React.useSyncExternalStore( subscribeReducedMotion, () => window.matchMedia(REDUCED_QUERY).matches, () => false ) } const GLYPHS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!<>-_/[]{}=+*^?" function ScrambleText({ children: text, as = "span", trigger = "mount", duration = 900, delay = 0, speed = 40, characters = GLYPHS, mono = false, onComplete, className, onPointerEnter, onFocus, ...props }: ScrambleTextProps) { const Comp = as as React.ElementType const rootRef = React.useRef<HTMLElement>(null) const outputRef = React.useRef<HTMLSpanElement>(null) const timers = React.useRef<{ interval?: number; timeout?: number }>({}) const reduceMotion = useReducedMotionSafe() const inView = useInView(rootRef, { once: true, margin: "0px 0px -10% 0px" }) const completeRef = React.useRef(onComplete) completeRef.current = onComplete const stop = React.useCallback(() => { window.clearInterval(timers.current.interval) window.clearTimeout(timers.current.timeout) }, []) const play = React.useCallback(() => { const output = outputRef.current if (!output || reduceMotion) return stop() const chars = Array.from(text) const frames = Math.max(1, Math.round(duration / speed)) // Left to right with a little jitter, so it reads as decoding rather than a wipe. const settleAt = chars.map((_, i) => 1 + Math.round((i / Math.max(chars.length, 1)) * frames * 0.7 + Math.random() * frames * 0.3)) const glyph = () => characters[Math.floor(Math.random() * characters.length)] ?? "" let frame = 0 const render = () => { output.textContent = chars.map((c, i) => (c.trim() === "" || frame >= settleAt[i]! ? c : glyph())).join("") } const start = () => { render() timers.current.interval = window.setInterval(() => { frame++ if (frame >= frames) { stop() output.textContent = text completeRef.current?.() } else render() }, speed) } if (delay > 0) { render() timers.current.timeout = window.setTimeout(start, delay) } else start() }, [text, duration, delay, speed, characters, reduceMotion, stop]) // Layout effect: the first scrambled frame replaces the final text before the browser paints. React.useLayoutEffect(() => { if (reduceMotion) { if (outputRef.current) outputRef.current.textContent = text return } if (trigger === "mount" || (trigger === "inView" && inView)) play() return stop }, [trigger, inView, play, stop, reduceMotion, text]) return ( <Comp ref={rootRef} data-slot="scramble-text" className={cn("relative inline-block", mono && "font-mono", className)} onPointerEnter={(event: React.PointerEvent<HTMLSpanElement>) => { if (trigger === "hover") play() onPointerEnter?.(event) }} onFocus={(event: React.FocusEvent<HTMLSpanElement>) => { if (trigger === "hover") play() onFocus?.(event) }} {...props} > <span className="sr-only">{text}</span> <span aria-hidden="true" className="relative block"> {/* Invisible final text holds the layout; the scrambled copy is drawn over it. */} <span className="invisible">{text}</span> <span ref={outputRef} data-slot="scramble-text-output" className="absolute inset-0"> {text} </span> </span> </Comp> ) } export { ScrambleText, type ScrambleTextProps }Update the import paths to match your project setup.
Usage
import { ScrambleText } from "@/components/ballmac/scramble-text"The full example is in the Code tab above.
Examples
On hover
import { ScrambleText } from "@/components/ballmac/scramble-text"
const links = ["Product", "Changelog", "Pricing", "Careers"]
export default function ScrambleTextHover() {
return (
<nav aria-label="Main" className="flex flex-wrap items-center justify-center gap-1 rounded-full border bg-card p-1.5">
{links.map((link) => (
<a
key={link}
href="#"
className="rounded-full px-3.5 py-1.5 text-sm font-medium text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<ScrambleText trigger="hover" mono duration={450} speed={30}>
{link.toUpperCase()}
</ScrambleText>
</a>
))}
</nav>
)
}API reference
| Prop | Type | Default |
|---|---|---|
children*The final text. | string | — |
asThe element to render. | ScrambleTextElement | "span" |
triggerWhen to play: on mount, when scrolled into view (once), or on every hover and keyboard focus. | "mount" | "inView" | "hover" | "mount" |
durationMilliseconds from the first scrambled frame until the last character settles. | number | 900 |
delayMilliseconds to wait before starting. | number | 0 |
speedMilliseconds between frames; lower values cycle glyphs faster. | number | 40 |
charactersGlyphs to cycle through before each character resolves. | string | GLYPHS |
monoUse a monospace font so the width never changes while scrambling. | boolean | false |
onCompleteCalled after the text has fully resolved. | () => void | — |
Also accepts the standard attributes of its root element.
Use with AI
<ScrambleText trigger="mount" | "inView" | "hover">Final text</ScrambleText>. Children must be a string. Frames are written straight to the DOM (no re-renders); an invisible copy of the final text reserves the layout and an sr-only copy is what assistive tech reads. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Scramble Text (@ballmac/scramble-text) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Terminal, security or developer-tool moments (status lines, keys, hashes)
- Short headings that reveal on scroll
- Nav links or buttons with a hover flourish (trigger="hover")
Not for
- Paragraphs or anything longer than a line
- Text that updates often (the scramble restarts each time)
Registry JSON: https://ui.ballmac.com/r/scramble-text.json
Credits
Free to use in personal and commercial projects.
- npm
- motion@^12
- Registry
- shadcn/utils
Pairs well with
Terminal
A terminal window with command, output, success, error and comment lines, optional per-command copy buttons and a sequenced typing animation that respects reduced motion.
Word Rotate
Cycles one word of a sentence with a vertical slide and blur while its width springs to the next word, so the rest of the line reflows smoothly. Screen readers hear one stable sentence.
Gradient Text
Text filled with a gradient of theme colors that slowly drifts, or a shiny variant that sweeps a glint of light across it on a pause. Static under reduced motion; plain text in forced colors.
Highlighter
Hand-drawn marks around inline text: highlight, underline, box, circle, strike-through and brackets, drawn in when the phrase scrolls into view.