# Text Scramble

> Text that scrambles through random glyphs before resolving, replaying on hover, focus or trigger change.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/text-scramble
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/text-scramble.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/text-scramble-demo.tsx"
"use client"

import * as React from "react"
import { RotateCw } from "lucide-react"

import { TextScramble } from "@/components/text-scramble"

const phrases = ["Design in motion", "Crafted by hand", "Decode the signal"]

export default function TextScrambleDemo() {
  const [index, setIndex] = React.useState(0)

  return (
    <div className="flex w-full flex-col items-center gap-6 py-6">
      <h2 className="text-4xl font-semibold tracking-tight sm:text-5xl">
        <TextScramble text={phrases[index]} trigger={index} />
      </h2>
      <p className="font-mono text-sm text-muted-foreground">
        <TextScramble text="hover me to scramble again" duration={1200} />
      </p>
      <button
        type="button"
        onClick={() => setIndex((i) => (i + 1) % phrases.length)}
        className="inline-flex h-9 items-center gap-2 rounded-lg border bg-background px-4 text-sm font-medium transition-colors outline-none hover:bg-accent focus-visible:ring-4 focus-visible:ring-ring/40"
      >
        <RotateCw className="size-4" /> Next phrase
      </button>
    </div>
  )
}
```

## Source

```tsx title="components/text-scramble.tsx"
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"

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

export interface TextScrambleProps extends Omit<React.ComponentProps<"span">, "children"> {
  /** The final text */
  text: string
  /** Total scramble time in ms */
  duration?: number
  /** Glyphs used while scrambling */
  chars?: string
  /** Change this value to replay the effect */
  trigger?: unknown
  /** Replay on hover and keyboard focus */
  scrambleOnHover?: boolean
}

// Random glyphs are only ever picked inside rAF callbacks, so render stays pure
// and the text is stable for SSR. `display === null` means "show the real text".
export function TextScramble({
  text,
  duration = 900,
  chars = DEFAULT_CHARS,
  trigger,
  scrambleOnHover = true,
  className,
  onMouseEnter,
  onFocus,
  ...props
}: TextScrambleProps) {
  const [display, setDisplay] = React.useState<string | null>(null)
  const rafRef = React.useRef(0)

  const run = React.useCallback(() => {
    cancelAnimationFrame(rafRef.current)
    const target = Array.from(text)
    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches
    if (reduced || target.length === 0) {
      rafRef.current = requestAnimationFrame(() => setDisplay(null))
      return
    }
    const glyphs = Array.from(chars)
    const resolveAt = target.map((_, i) => ((i / target.length) * 0.6 + Math.random() * 0.4) * duration)
    let startTime = 0
    let lastSwap = 0
    let current = target

    const frame = (now: number) => {
      if (!startTime) startTime = now
      const elapsed = now - startTime
      if (elapsed >= duration) {
        setDisplay(null)
        return
      }
      if (now - lastSwap > 45) {
        lastSwap = now
        current = target.map((ch, i) =>
          ch === " " || elapsed >= resolveAt[i] ? ch : glyphs[Math.floor(Math.random() * glyphs.length)]
        )
        setDisplay(current.join(""))
      }
      rafRef.current = requestAnimationFrame(frame)
    }
    rafRef.current = requestAnimationFrame(frame)
  }, [text, chars, duration])

  React.useEffect(() => {
    run()
    return () => cancelAnimationFrame(rafRef.current)
  }, [run, trigger])

  return (
    <span
      className={cn("inline-block rounded-sm", className)}
      onMouseEnter={(e) => {
        onMouseEnter?.(e)
        if (scrambleOnHover) run()
      }}
      onFocus={(e) => {
        onFocus?.(e)
        if (scrambleOnHover) run()
      }}
      {...props}
    >
      <span className="sr-only">{text}</span>
      <span aria-hidden>{display ?? text}</span>
    </span>
  )
}
```
