Decode Text
Text that resolves out of glyph noise, left to right, without any layout shift.
npx shadcn@latest add https://sundaeui.dev/r/decode-text.json'use client'
import { useEffect, useRef, useState } from 'react'
export interface DecodeTextProps {
text: string
/** Glyphs used while scrambling. */
glyphs?: string
/** Total duration in ms. */
duration?: number
/** When to run: on mount, when scrolled into view, or on hover/focus. */
trigger?: 'mount' | 'view' | 'hover'
as?: 'span' | 'h1' | 'h2' | 'h3' | 'p'
className?: string
style?: React.CSSProperties
}
const GLYPHS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#%&*+=/<>'
/**
* Text that resolves out of noise, left to right. Each character keeps its
* final width (monospace grid per character), so nothing reflows while it runs.
*/
export function DecodeText({ text, glyphs = GLYPHS, duration = 1100, trigger = 'view', as: Tag = 'span', className, style }: DecodeTextProps) {
const ref = useRef<HTMLElement>(null)
const [out, setOut] = useState(text)
const raf = useRef(0)
const run = () => {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return setOut(text)
cancelAnimationFrame(raf.current)
const start = performance.now()
const chars = Array.from(text)
const tick = (now: number) => {
const t = Math.min(1, (now - start) / duration)
const next = chars
.map((c, i) => {
if (c === ' ') return ' '
// Each character resolves at its own moment, front-weighted.
const at = (i / chars.length) * 0.7 + 0.25
return t >= at ? c : glyphs[(Math.random() * glyphs.length) | 0]
})
.join('')
setOut(next)
if (t < 1) raf.current = requestAnimationFrame(tick)
}
raf.current = requestAnimationFrame(tick)
}
useEffect(() => {
if (trigger === 'mount') run()
if (trigger !== 'view' || !ref.current) return
const io = new IntersectionObserver(([e]) => {
if (e.isIntersecting) {
run()
io.disconnect()
}
})
io.observe(ref.current)
return () => {
io.disconnect()
cancelAnimationFrame(raf.current)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [text, trigger])
const T = Tag as React.ElementType
return (
<T
ref={ref}
className={className}
style={style}
aria-label={text}
onMouseEnter={trigger === 'hover' ? run : undefined}
onFocus={trigger === 'hover' ? run : undefined}
>
<span aria-hidden="true" style={{ display: 'inline-block', whiteSpace: 'pre-wrap' }}>
{Array.from(out).map((c, i) => (
<span
key={i}
style={{
display: 'inline-block',
// Hold each character to the width of its final glyph.
width: text[i] === ' ' ? '0.3em' : undefined,
minWidth: '0.55em',
textAlign: 'center',
opacity: c === text[i] ? 1 : 0.55,
transition: 'opacity 160ms',
}}
>
{c}
</span>
))}
</span>
</T>
)
}Overview
Characters cycle through random glyphs and lock into their final letters in sequence, like a terminal decrypting a transmission. Unresolved glyphs sit at reduced opacity, so the reveal reads as a front moving across the line.
Each character is held in its own fixed-width box, so the line never reflows or jitters while it animates. Screen readers get the real text immediately through aria-label.
Trigger it on mount, when it scrolls into view, or on hover and focus. Use it for headlines, loading states and status lines in futuristic or developer-facing interfaces.
When to use
- Headlines, numbers and moments you want people to notice once.
When not to use
- Text that changes constantly or must be read at a glance.
Installation
pnpm dlx shadcn@latest add https://sundaeui.dev/r/decode-text.jsonnpx shadcn@latest add https://sundaeui.dev/r/decode-text.jsonyarn dlx shadcn@latest add https://sundaeui.dev/r/decode-text.jsonbunx --bun shadcn@latest add https://sundaeui.dev/r/decode-text.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy decode-text.tsx into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { DecodeText } from '@/components/sundae/decode-text'
<DecodeText as="h1" text="HANDSHAKE COMPLETE" trigger="view" duration={1200} />Props
| Prop | Type | Default |
|---|---|---|
text*Final text. | string | — |
triggerWhen the decode runs. | 'mount' | 'view' | 'hover' | 'view' |
durationTotal duration in ms. | number | 1100 |
glyphsCharacters used for noise. | string | — |
asElement to render. | 'span' | 'h1' | 'h2' | 'h3' | 'p' | 'span' |
Accessibility
- The element carries aria-label with the real text, and the animated glyphs are aria-hidden.
- With prefers-reduced-motion, the final text appears immediately.
- The hover trigger also runs on keyboard focus.
Customization
- Works best with monospace or tabular fonts. For proportional fonts, reduce the per-character minWidth.
- Pass glyphs="01" for a binary look, or "░▒▓" for blocks.
FAQ
- Is Decode Text free?
- Yes. Decode Text is free for personal and commercial projects, with full source on this page.
- How do I install Decode Text?
- Run npx shadcn@latest add https://sundaeui.dev/r/decode-text.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Decode Text support keyboard and screen readers?
- The element carries aria-label with the real text, and the animated glyphs are aria-hidden. With prefers-reduced-motion, the final text appears immediately.
- Does Decode Text respect reduced motion?
- Yes. With prefers-reduced-motion set, decorative motion is removed and state changes are instant.
More like this
All motion →- Pro
Word Morph
Motion · Expressive - Pro
Split-Flap Display
Motion · Brutalist - Pro
Magnetic Button
Motion · Playful - Pro
Thinking Shimmer
Motion · Understated Margin Quote
Sections · Editorial- Pro
Billboard Hero
Sections · Brutalist