Letter-by-letter text animation is one of those effects that's simultaneously simple and deceptively deep. At its core, it's just a CSS animation applied to a bunch of spans with incrementally increasing delays. But the quality of that motion, whether it feels mechanical or organic, instant or graceful, comes down to a few key decisions about math and timing.
This post walks through the mechanics I used to build the Text Animator tool on this site. Each concept has a live demo you can tweak.
1. Splitting Text into Spans
The first step is breaking your string into individually animatable units. For letter-by-letter animation, that means one <span> per character. For word animation, one span per word.
There's one gotcha with letter splitting: spaces. When you render a plain space inside a display: inline-block element, it collapses. The fix is to replace spaces with a non-breaking space ( ), or in JSX, the unicode equivalent '\u00A0'.
// Letter split — replace spaces to prevent collapse
const parts = text.split('').map((char, i) => (
<span
key={i}
style={{ display: 'inline-block' }}
>
{char === ' ' ? '\u00A0' : char}
</span>
))
// Word split — use margin instead
const words = text.split(' ').filter(Boolean)
const parts = words.map((word, i) => (
<span
key={i}
style={{
display: 'inline-block',
marginRight: i < words.length - 1 ? '0.35em' : 0,
}}
>
{word}
</span>
))display: inline-block? Purely inline elements don't respond to transform properties liketranslateY. Making each span inline-block gives it its own rendering context while keeping characters flowing in a line.Hello World
11 spans · spaces become so they don't collapse
2. Defining the Animation Keyframe
The actual motion is a CSS @keyframes rule. A simple fade-up looks like this:
@keyframes fadeUp {
from {
opacity: 0;
transform: translateY(14px);
}
to {
opacity: 1;
transform: translateY(0);
}
}You can combine as many properties as you like: opacity, translateY, scale, rotate, blur. The keyframe itself only defines the shape of a single element's motion. The stagger comes from the animation-delay.
3. Stagger: The Heartbeat of Text Animation
Without stagger, every letter animates simultaneously; you just see a fade, not a sequence. Stagger is the offset between each character's start time. It's as simple as multiplying the index by a delay value:
const STAGGER = 55 // ms between each character
chars.map((char, i) => (
<span
key={`${playKey}-${i}`}
style={{
display: 'inline-block',
animation: `fadeUp_${playKey} 480ms ease forwards both`,
animationDelay: `${i * STAGGER}ms`,
}}
>
{char}
</span>
))animation-fill-mode: both is important here. backwards applies thefrom keyframe before the animation starts (keeping the element hidden during its delay), and forwards holds the final frame after it ends. Together, they make stagger just work without extra opacity juggling.
Motiondesign
Notice how increasing the stagger delay makes the sequence feel more deliberate and dramatic, while a low stagger (close to 0ms) makes it feel like a blur wipe. Most satisfying ranges sit between 40–80ms.
4. Wave Stagger with Math.sin
Linear stagger is predictable. For something that feels more organic, characters arriving in a rolling wave, we can modulate each delay using a sine curve.
const getDelay = (i, stagger, waveFreq, waveAmp) => {
const base = i * stagger // linear ramp (ms)
const wave = Math.sin(i * waveFreq) * waveAmp * 1000 // sine offset (ms)
return Math.max(0, base + wave) // clamp to ≥ 0
}
// Usage:
animationDelay: `${getDelay(i, 50, 0.65, 0.05)}ms`The waveFreq controls how many oscillations appear across the text; a higher frequency creates shorter, tighter waves. The waveAmp scales the offset magnitude. We Math.max(0, ...) the result because Math.sin can go negative, which would produce a negative delay (treated as 0 by browsers, but causing characters to overlap unexpectedly).
Wavemotion
5. Overshoot Easing with cubic-bezier
The easing function defines the shape of a single element's motion curve. Standard easings like ease-out decelerate to a stop. For a springy, physical-feeling motion, where the element overshoots its target then snaps back; we need a custom cubic-bezier.
/* Standard ease-out */
animation-timing-function: ease-out;
/* Overshoot — the 2nd control point y > 1 goes past the target */
animation-timing-function: cubic-bezier(0.34, 1.6, 0.64, 1);
/* ^^^
Raise this above 1.0 to overshoot.
1.0 = no overshoot, 2.0 = strong spring */In a cubic-bezier, the four values are the x and y coordinates of two control points. When the y coordinate of the second control point exceeds 1 (or goes below 0), the curve passes outside the [0,1] range, producing values above the target, which creates the overshoot. In React you can generate this dynamically:
const overshoot = 0.6 // tweak this value
const easing = `cubic-bezier(0.34, ${(1 + overshoot).toFixed(2)}, 0.64, 1)`Easeout
Easein-out
Overshoot
Compare ease-out (clean stop), ease-in-out (symmetric curve), and overshoot side by side. The overshoot version feels significantly more alive even at the same duration; that brief moment of overshooting is what tricks your eye into reading it as physical.
6. Putting It Together
Here's a complete, reusable React component that combines all the techniques above. Drop it into any project and pass props to control the behaviour.
'use client'
import { useState } from 'react'
export function TextAnimator({
text,
stagger = 55, // ms between characters
duration = 480, // ms per character animation
easing = 'ease', // CSS easing or cubic-bezier string
wave = null, // { freq: 0.65, amp: 0.05 } or null
}) {
const [playKey, setPlayKey] = useState(0)
const getDelay = (i) => {
const base = i * stagger
if (!wave) return base
return Math.max(0, base + Math.sin(i * wave.freq) * wave.amp * 1000)
}
const kf = `
@keyframes ta_${playKey} {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
`
return (
<div>
<style dangerouslySetInnerHTML={{ __html: kf }} />
<p>
{text.split('').map((char, i) => (
<span
key={`${playKey}-${i}`}
style={{
display: 'inline-block',
...(char === ' ' && { marginRight: '0.35em' }),
...(playKey > 0 && {
animation:
`ta_${playKey} ${duration}ms ${easing} ${getDelay(i).toFixed(0)}ms both`,
}),
}}
>
{char !== ' ' ? char : null}
</span>
))}
</p>
<button onClick={() => setPlayKey(k => k + 1)}>
Animate
</button>
</div>
)
}
// Usage:
// <TextAnimator text="Hello World" stagger={55} />
// <TextAnimator text="Wave effect" wave={{ freq: 0.65, amp: 0.05 }} />
// <TextAnimator text="Spring!" easing="cubic-bezier(0.34,1.6,0.64,1)" />A few things worth calling out in this component: the playKey is included in both the keyframe name and the span key prop. This double-barrelled approach ensures the animation restarts even if React decides to reuse a DOM node. The wave prop is optional; when omitted, delays are purely linear.
Where to Go from Here
The techniques here are the foundation, but there's a lot of room to extend them. Some directions worth exploring:
- →Direction. Animate from right-to-left by reversing the delay order: delay(textLength - 1 - i).
- →More properties. Add scale, rotateX, or blur to the keyframe. Blur (filter: blur(4px)) especially adds atmosphere.
- →Exit animations. Mirror the enter animation with a reversed keyframe and trigger it before unmounting.
- →Scroll triggers. Use an IntersectionObserver to set playKey when the element enters the viewport.
- →Spring physics. Replace CSS cubic-bezier with a JavaScript spring library (react-spring, motion) for more control over mass and damping.
If you want to experiment visually with all these controls at once, the Text Animator tool lets you tweak everything in real time: split mode, stagger, wave, easing, opacity, scale, blur. Preview the result before writing a single line of code.