Text Scramble
On hover, each character flickers through random glyphs and locks into place from left to right, like a display decoding.
Write a dependency-free React text scramble that plays on hover and keyboard focus. Keep the target string, and on start run a requestAnimationFrame loop for about 700ms. Each frame, characters before the progress index show their final letter, and the rest show a random glyph from the pool "ABCDEFGHIJKLMNOPQRSTUVWXYZ#%&*+=/<>". Keep spaces as spaces. Cancel the loop on unmount and when a new run starts. Use a monospace font so the width does not jump, and put the real text in aria-label so screen readers never hear the noise. Under prefers-reduced-motion, show the final text without scrambling. The text reads "Decode the signal".
Source
'use client'
import { useEffect, useRef, useState } from 'react'
import s from './demo.module.css'
const DURATION = 700
export default function TextScramble({ labels }: { labels: Record<string, string> }) {
const target = labels.text
const pool = Array.from(labels.glyphs)
const [shown, setShown] = useState(target)
const frame = useRef(0)
useEffect(() => () => cancelAnimationFrame(frame.current), [])
const run = () => {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
cancelAnimationFrame(frame.current)
const chars = Array.from(target)
const start = performance.now()
const tick = (now: number) => {
const progress = Math.min((now - start) / DURATION, 1)
const locked = Math.floor(progress * chars.length)
setShown(
chars
.map((c, i) => (i < locked || c === ' ' ? c : pool[Math.floor(Math.random() * pool.length)]))
.join(''),
)
if (progress < 1) frame.current = requestAnimationFrame(tick)
}
frame.current = requestAnimationFrame(tick)
}
return (
<span className={s.text} tabIndex={0} aria-label={target} onPointerEnter={run} onFocus={run}>
<span aria-hidden="true">{shown}</span>
</span>
)
}
.text {
padding: 8px 12px;
border-radius: var(--radius-sm);
font: 600 var(--text-2xl) / 1.2 var(--font-mono);
letter-spacing: -0.01em;
color: var(--stage-ink);
white-space: nowrap;
cursor: default;
}
.text:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
}