Always cooking something
← All posts
March 15, 2026·8 min read

Building Letter-by-Letter Text Animations in React

ReactCSS AnimationsMotion

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 (&nbsp;), or in JSX, the unicode equivalent '\u00A0'.

jsx
// 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>
))
Why 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.
Interactive ·The Split

Hello World

11 spans · spaces become &nbsp; 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:

css
@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:

jsx
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.

Interactive ·Stagger
Stagger delay55ms
Duration480ms

Motiondesign

Total duration: 1.14s

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.

jsx
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).

Interactive ·Wave Stagger
Base stagger50ms
Wave frequency0.65
Wave amplitude0.05

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.

css
/* 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:

jsx
const overshoot = 0.6 // tweak this value
const easing = `cubic-bezier(0.34, ${(1 + overshoot).toFixed(2)}, 0.64, 1)`
Interactive ·Easing Comparison
Overshoot amount0.60
ease-out

Easeout

ease-in-out

Easein-out

cubic-bezier overshoot

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.

jsx
'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.