To animate text on the web, first decide what you're actually animating. A headline that fades up is an ordinary element animation: opacity and y on the <h1>, nothing special. A headline whose letters arrive one at a time needs the text split into individual elements before you can touch it. A price that ticks from £29 to £49 isn't animating text at all, it's animating a number and reformatting it as it goes.
Characters, words, lines, values. Each one wants a different technique, and reaching for the wrong one is why so many text animations feel expensive and read badly.
Here's the map.
Splitting text
CSS can't do this for you. There's ::first-letter, and that's the end of the list: no ::nth-char(), no way to select the third word or the second line. Per-character motion means putting each character in its own element, which means rewriting the DOM.
That rewrite has a cost you can't see on screen. A screen reader that was happily announcing "Level up your animations" now finds two dozen separate spans and can end up reading them as letter soup. The fix is to carry the original string on the container as an aria-label before you replace its contents.
Motion's splitText does exactly that. It reads the element's textContent, sets it as the container's aria-label, then swaps the text for spans and hands you back chars, words and lines as plain arrays of elements. It's part of Motion+.
import { animate, stagger } from "motion"
import { splitText } from "motion-plus"
useEffect(() => {
const { chars } = splitText(ref.current)
animate(chars, { opacity: [0, 1], y: [12, 0] }, { delay: stagger(0.03) })
}, [])
Those are ordinary DOM elements, so you aren't tied to Motion for the animation itself. Style them with CSS, or attach a hover handler to every word.
Two things to know before you ship. Line splitting depends on measuring text that has already been laid out, so a web font arriving late will split in the wrong places: wait on document.fonts.ready first. And the element is briefly empty mid-rewrite, so keep the container hidden until you're ready to animate. Both, along with what happens to links, hyphenation and justified text, are on the splitText reference. That page is written against vanilla JavaScript, but it's the same function in React, called from an effect.
Staggering
Split text with everything animating at once looks identical to animating the whole headline, only more expensively. The stagger is the effect.
stagger() takes a time in seconds and spreads it across the list, pushing each element's delay one step further than the last.
animate(chars, { opacity: 1 }, { delay: stagger(0.03) })
In React you can hand the same function to delayChildren on a parent variant, and children inherit the distribution as they enter:
const container = {
visible: {
transition: { delayChildren: stagger(0.05, { from: "center" }) },
},
}
from sets the origin ("first", "center", "last", or an index) and ease redistributes the delays across the total time, which is what turns a mechanical left-to-right sweep into something that reads as designed. Full options are on the stagger reference, and there's a worked example to pull apart.
Typewriter and scramble
Two effects that get asked for by name.
A typewriter types content out character by character. The naive version is a setInterval that appends a letter every 50ms, and it's immediately obvious why that's wrong: real typing isn't metronomic, and when the text changes you want it to backspace to the point of difference rather than clear and start over. Motion+'s Typewriter does both, and keeps the full string in an ARIA label so it isn't announced one letter at a time.
import { Typewriter } from "motion-plus/react"
<Typewriter speed="slow">Hello world!</Typewriter>
Scramble cycles random characters through each position before settling on the real one. ScrambleText renders through a motion value rather than React state, so the per-frame character churn never reaches React's render cycle. Pass stagger to delay and the characters resolve in sequence.
import { stagger } from "motion"
import { ScrambleText } from "motion-plus/react"
<ScrambleText delay={stagger(0.05, { from: "center" })}>
Hello world!
</ScrambleText>
Both are Motion+ components, and there's a vanilla scrambleText too. Worth a look: hover-triggered scramble and natural typing speeds.
Animating numbers
Counters, prices, stats and countdowns look like text animation but aren't. Nothing gets split. You're animating a number and reformatting it, and the interesting part is making the digits move rather than snap.
AnimateNumber is built on Motion's layout animations, so digits slide as the number of them changes, and formatting goes through Intl.NumberFormat.
import { AnimateNumber } from "motion-plus/react"
<AnimateNumber format={{ style: "currency", currency: "GBP" }}>
{price}
</AnimateNumber>
It takes the usual transition prop, so putting a spring on the digits is one line. Motion+ again. See it as a counter, a price switcher and scroll-triggered stats.
Tickers and marquees
A continuously-scrolling strip of logos, headlines or testimonials. The hand-rolled version duplicates the content, translates it by -50% on a loop, and falls apart the moment the container resizes or somebody tabs into it.
Ticker clones only as many items as it takes to fill the container, handles reduced motion and keyboard focus, and runs on either axis.
import { Ticker } from "motion-plus/react"
<Ticker items={items} velocity={80} />
Because it's driven by a motion value you can also take the wheel and feed it scroll position or a drag gesture. Motion+. There's a logo ticker, a vertical one, a scroll-driven one, and a write-up of how the renderer decides what to clone.
Revealing text on scroll
The one everybody wants. It's split text plus a viewport trigger, and the only real decision is which trigger.
For "animate once, when it arrives", whileInView with viewport={{ once: true }} is the whole thing:
<motion.p
initial={{ opacity: 0, y: 12 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.5 }}
>
Nothing happens until half of me is on screen.
</motion.p>
For text that tracks the scrollbar continuously, word by word as the reader moves, you want useScroll and a progress-driven transform instead. Both approaches are covered in scroll animations, and useInView is the escape hatch for when you need the state rather than the animation.
There's also a split text hero and a text reveal effect to borrow from.
What splitting costs
Splitting is the one technique here with a real bill attached, so let's be blunt about it. One headline becomes fifty elements. Fifty elements animating transform and opacity is fine: those run on the compositor, and browsers handle a lot of cheap layers happily.
Fifty elements animating something that triggers layout is not fine. width, margin, top and friends force the browser to reflow the line, and you've just handed it fifty chances per frame to do that.
Blur is the classic trap. filter is increasingly handled on the compositor, which makes it tempting, but a blur is still a per-pixel operation over each element's box, and you've multiplied the number of boxes by the number of characters in the headline. If a blur-in is the effect you want, try it on words rather than characters, and check it on a mid-range phone before you commit.
Two more, quickly. Don't split long-form copy: splitting is for headlines and short lines, and running it over paragraphs costs you elements, memory and the reading experience for nothing. And honour prefers-reduced-motion, because a per-character stagger is precisely the sort of motion people turn that setting on to escape.
Accessibility covers how to do that, and animation performance has the property-by-property breakdown.
Pick the smallest unit the effect actually needs. Most text animations that feel wrong are animating characters when words would have done.
FAQs
- How do I animate text letter by letter?
Split the text into per-character elements first, then stagger their animations.
splitTextreturnschars,wordsandlinesas arrays you can pass straight toanimatewithdelay: stagger(0.03).- Can CSS animate individual letters on its own?
No. CSS has
::first-letterand nothing beyond it, so there's no selector for the third character or the second line. Something has to put each character in its own element first.- Does splitting text break screen readers?
It does if the container isn't labelled, because the reader finds a run of single-character spans instead of a sentence.
splitTextsets the original string as anaria-labelon the container before it rewrites the contents.- Which properties are cheapest to animate on split text?
transformandopacity. Both run on the compositor, so the cost of animating many characters at once stays low. Layout properties and per-character blur are where split text starts to jank.