Word Reveal
Text arriving one word at a time.
dark onlyText arriving one word at a time.
dark onlyexperiments/word-reveal/index.tsx
const TEXT = 'Type is not decoration. It is the interface telling you how to read it.'
const STEP_MS = 55
/**
* The original set a setTimeout per word with no cleanup, so it double-fired
* under StrictMode and leaked timers on unmount. This has no timers: the stagger
* is animation-delay, so there is nothing to clean up, and LazyMount remounting
* replays it for free.
*/
export default function WordReveal({ variant = 'Rise' }: { variant?: string }) {
const source = variant === 'Chars' ? [...TEXT] : TEXT.split(' ')
const animation = variant === 'Blur' ? 'word-reveal-blur' : 'word-reveal-rise'
// Position is the identity: the same word recurs in the sentence, and each
// occurrence carries its own delay.
const units = source.map((text, index) => ({
id: `${index}-${text}`,
text: variant === 'Chars' ? text : `${text} `,
delay: index * STEP_MS,
}))
return (
<div className="max-w-md [--wr-ink:#f2f2f4] text-center">
<style>{KEYFRAMES}</style>
<p className="text-xl font-semibold leading-relaxed tracking-[-0.02em] text-[var(--wr-ink)]">
{units.map((unit) => (
<span
key={unit.id}
style={{
display: 'inline-block',
// Keeps the trailing space inside the inline-block so words wrap
// naturally instead of running together.
whiteSpace: 'pre',
opacity: 0,
animation: `${animation} 520ms cubic-bezier(0.2, 0, 0, 1) ${unit.delay}ms forwards`,
}}
>
{unit.text}
</span>
))}
</p>
</div>
)
}
const KEYFRAMES = `
@keyframes word-reveal-rise {
from { opacity: 0; transform: translateY(0.4em); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes word-reveal-blur {
from { opacity: 0; filter: blur(6px); }
to { opacity: 1; filter: blur(0); }
}
@media (prefers-reduced-motion: reduce) {
@keyframes word-reveal-rise { from { opacity: 1; transform: none; } to { opacity: 1; transform: none; } }
@keyframes word-reveal-blur { from { opacity: 1; filter: none; } to { opacity: 1; filter: none; } }
}
`