# Random Letter Swap Button

> CTA button whose label scrambles into random glyphs on hover or focus, then resolves letter by letter.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/random-letter-swap-button
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/random-letter-swap-button.json
```

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

## Dependencies

No extra dependencies.

## Usage

```tsx title="components/random-letter-swap-button-demo.tsx"
import { RandomLetterSwapButton } from "@/components/random-letter-swap-button"

export default function RandomLetterSwapButtonDemo() {
  return (
    <div className="flex flex-wrap items-center justify-center gap-4 p-8">
      <RandomLetterSwapButton>Get early access</RandomLetterSwapButton>
      <RandomLetterSwapButton speed={25} className="bg-primary text-primary-foreground">
        Join the waitlist
      </RandomLetterSwapButton>
    </div>
  )
}
```

## Source

```tsx title="components/random-letter-swap-button.tsx"
"use client"

import * as React from "react"

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

const GLYPHS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789#$%&@"

export interface RandomLetterSwapButtonProps extends Omit<React.ComponentProps<"button">, "children"> {
  children: string
  /** Milliseconds per scramble frame */
  speed?: number
}

export function RandomLetterSwapButton({
  children,
  speed = 40,
  className,
  onMouseEnter,
  onMouseLeave,
  onFocus,
  onBlur,
  ...props
}: RandomLetterSwapButtonProps) {
  const [display, setDisplay] = React.useState<string | null>(null)
  const timer = React.useRef<ReturnType<typeof setInterval> | null>(null)

  const stop = React.useCallback(() => {
    if (timer.current) clearInterval(timer.current)
    timer.current = null
    setDisplay(null)
  }, [])

  const start = React.useCallback(() => {
    if (timer.current) clearInterval(timer.current)
    let frame = 0
    const total = children.length
    timer.current = setInterval(() => {
      frame += 1
      const resolved = Math.floor(frame / 2)
      const next = children
        .split("")
        .map((char, index) =>
          char === " " || index < resolved ? char : GLYPHS[Math.floor(Math.random() * GLYPHS.length)]
        )
        .join("")
      if (resolved >= total) {
        stop()
        return
      }
      setDisplay(next)
    }, speed)
  }, [children, speed, stop])

  React.useEffect(
    () => () => {
      if (timer.current) clearInterval(timer.current)
    },
    []
  )

  return (
    <button
      className={cn(
        "relative inline-flex h-12 items-center justify-center rounded-lg bg-foreground px-8 font-mono text-sm font-semibold tracking-wide text-background",
        "transition-transform duration-150 active:scale-[0.97]",
        "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:outline-none",
        "disabled:pointer-events-none disabled:opacity-50",
        className
      )}
      aria-label={children}
      onMouseEnter={(event) => {
        start()
        onMouseEnter?.(event)
      }}
      onMouseLeave={(event) => {
        stop()
        onMouseLeave?.(event)
      }}
      onFocus={(event) => {
        start()
        onFocus?.(event)
      }}
      onBlur={(event) => {
        stop()
        onBlur?.(event)
      }}
      {...props}
    >
      <span aria-hidden className="relative inline-block whitespace-pre">
        <span className="invisible">{children}</span>
        <span className="absolute inset-0 text-center">{display ?? children}</span>
      </span>
    </button>
  )
}
```
