CSS Text Animations: 17 Examples You Can Copy Today
Animated text is everywhere. HTTP Archive's 2020 Web Almanac found the animation property on 73% of mobile pages, while only 24% checked prefers-reduced-motion.
Below are 17 CSS text animations you can try and copy, followed by a guide to how they work, what they cost and how to keep them accessible.
Text that animates in once. Each demo plays when it scrolls into view and has a Replay button, and the script only hides the text right before it plays, so the copy stays readable if JavaScript or motion is off.
Typewriter with a blinking caret
steps()
ch units
finite caret blink
deploy.sh
$ ship deploy --prod
Uploaded 14 pages in 2.3 s
Deployed to production
Plays when it scrolls into view. Use Replay to run it again.
The command is 20 characters in a monospace font, so the line animates its width from 0 to 20ch in steps(20) and every step reveals exactly one character. The caret is a right border that blinks 8 times at 0.6 s and then stops, which keeps it inside the five-second limit.
Seasonal produce boxes, packed the morning they ship.
Plays when it scrolls into view. Use Replay to run it again.
Each letter is an inline-block span with its index in --ta-i, and animation-delay is calc(var(--ta-i) * 45ms). The heading carries the whole word in aria-label and the letters are aria-hidden, so a screen reader announces one word instead of seven letters.
Fridays from 6 pm on Pier 9, forty stalls and a brass band.
Plays when it scrolls into view. Use Replay to run it again.
A coral bar grows across each line, then shrinks toward the right while the text underneath opens with clip-path: inset(), so the letters appear exactly where the bar leaves. The bar uses transform only, and the clip runs once per line, which keeps the paint cost small.
<div class="ta-wipe">
<h4 class="ta-wipe__title"><span class="ta-wipe__line" style="--ta-i: 0"><span>The night</span></span><span class="ta-wipe__line" style="--ta-i: 1"><span>market is open</span></span></h4>
<p class="ta-wipe__meta">Fridays from 6 pm on Pier 9, forty stalls and a brass band.</p>
<button class="ta-wipe__replay" type="button">Replay</button>
</div>
<script>
document.querySelectorAll('.ta-wipe').forEach(function (root) {
function play() {
root.classList.remove('is-play');
void root.offsetWidth;
root.classList.add('is-play');
}
root.classList.add('ta-js');
if ('IntersectionObserver' in window) {
new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) { play(); }
});
}, { threshold: 0.6 }).observe(root);
} else {
play();
}
root.querySelector('.ta-wipe__replay').addEventListener('click', play);
});
</script>
Masked lines that slide up
overflow: hidden mask
translateY(110%)
descender padding
Slow morningsby the canal
Travel journal, 6 min read
Plays when it scrolls into view. Use Replay to run it again.
Each headline line sits in its own overflow: hidden box, and the inner span slides up from below the mask. Only transform animates, so this is the cheapest reveal in the set. The mask has a little bottom padding so the descenders on g and y are not cut off.
<article class="ta-mask">
<img src="canal-houses.webp" alt="Old brick and white houses along a canal, with two small boats moored by a wooden jetty" width="800" height="600" loading="lazy">
<div class="ta-mask__body">
<h4 class="ta-mask__title"><span class="ta-mask__line" style="--ta-i: 0"><span>Slow mornings</span></span><span class="ta-mask__line" style="--ta-i: 1"><span>by the canal</span></span></h4>
<p class="ta-mask__meta">Travel journal, 6 min read</p>
<button class="ta-mask__replay" type="button">Replay</button>
</div>
</article>
<script>
document.querySelectorAll('.ta-mask').forEach(function (root) {
function play() {
root.classList.remove('is-play');
void root.offsetWidth;
root.classList.add('is-play');
}
root.classList.add('ta-js');
if ('IntersectionObserver' in window) {
new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) { play(); }
});
}, { threshold: 0.6 }).observe(root);
} else {
play();
}
root.querySelector('.ta-mask__replay').addEventListener('click', play);
});
</script>
Blur-in focus pull
filter: blur()
word stagger
scale
Lessnoise.Moresignal.
Focus mode mutes every channel except the three you pin.
Plays when it scrolls into view. Use Replay to run it again.
Words start blurred, slightly enlarged and transparent, then settle into focus one after another, like a camera pulling focus. Splitting by word instead of by letter keeps kerning intact and lets screen readers read the sentence normally. Blur is a paint effect, so keep it to a short headline.
<div class="ta-blur">
<h4 class="ta-blur__title"><span style="--ta-i: 0">Less</span> <span style="--ta-i: 1">noise.</span> <span style="--ta-i: 2">More</span> <span style="--ta-i: 3">signal.</span></h4>
<p class="ta-blur__sub">Focus mode mutes every channel except the three you pin.</p>
<button class="ta-blur__replay" type="button">Replay</button>
</div>
<script>
document.querySelectorAll('.ta-blur').forEach(function (root) {
function play() {
root.classList.remove('is-play');
void root.offsetWidth;
root.classList.add('is-play');
}
root.classList.add('ta-js');
if ('IntersectionObserver' in window) {
new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) { play(); }
});
}, { threshold: 0.6 }).observe(root);
} else {
play();
}
root.querySelector('.ta-blur__replay').addEventListener('click', play);
});
</script>
Looping text with a way to stop it
Rotating words, a shimmer, a ticker and a breathing cue. Anything that loops either ends within five seconds, waits for a click to start, or comes with a visible pause button, which is what WCAG 2.2.2 asks for.
Rotating words in a headline
overflow mask
keyframe holds
animation-play-state
Payroll that works for freelancers, studios and agenciesfreelancersstudiosagenciesfreelancers
Run payslips, taxes and contractor invoices from one ledger.
Use Pause animation to stop the loop.
The three audiences sit in a vertical list inside a one-line mask, with the first word repeated at the end so the loop restarts without a jump. Keyframes hold each word for about two seconds. Screen readers get one plain sentence from a visually hidden span, and the pause button stops the loop, as WCAG 2.2.2 requires for motion that runs longer than five seconds.
<div class="ta-rot">
<h4 class="ta-rot__title">Payroll that works for <span class="ta-rot__sr">freelancers, studios and agencies</span><span class="ta-rot__slot" aria-hidden="true"><span class="ta-rot__list"><span>freelancers</span><span>studios</span><span>agencies</span><span>freelancers</span></span></span></h4>
<p class="ta-rot__sub">Run payslips, taxes and contractor invoices from one ledger.</p>
<button class="ta-rot__toggle" type="button" aria-pressed="false">Pause animation</button>
</div>
<script>
document.querySelectorAll('.ta-rot').forEach(function (root) {
var toggle = root.querySelector('.ta-rot__toggle');
toggle.addEventListener('click', function () {
var paused = toggle.getAttribute('aria-pressed') !== 'true';
toggle.setAttribute('aria-pressed', String(paused));
root.classList.toggle('is-paused', paused);
});
});
</script>
Shimmer on a status line
background-clip: text
@supports
finite iterations
Generating your quarterly report
Step 2 of 3: matching 1,284 invoices
Plays three times when it scrolls into view. Use Replay to run it again.
A light band inside a solid-color background sweeps across the letters through background-clip: text. This is the one demo in the set where a gradient is the technique itself. The sweep runs three times in 4.8 seconds and stops, and browsers without background-clip: text keep plain readable text because the clip sits inside @supports.
<div class="ta-shim">
<p class="ta-shim__text" role="status">Generating your quarterly report</p>
<p class="ta-shim__step">Step 2 of 3: matching 1,284 invoices</p>
<div class="ta-shim__rows" aria-hidden="true"><i></i><i></i><i></i></div>
<button class="ta-shim__replay" type="button">Replay</button>
</div>
<script>
document.querySelectorAll('.ta-shim').forEach(function (root) {
function play() {
root.classList.remove('is-play');
void root.offsetWidth;
root.classList.add('is-play');
}
if ('IntersectionObserver' in window) {
new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) { play(); }
});
}, { threshold: 0.6 }).observe(root);
} else {
play();
}
root.querySelector('.ta-shim__replay').addEventListener('click', play);
});
</script>
Announcement ticker with a pause button
two-track loop
animation-play-state
:focus-within
Free returns within 30 days
Autumn knitwear is in
Studio open Saturdays, 10 to 6
Gift cards from 20 euros
Free returns within 30 days
Autumn knitwear is in
Studio open Saturdays, 10 to 6
Gift cards from 20 euros
Wool & WardWomenMenJournal
Hover the bar or use Pause ticker to stop it.
Two copies of the list sit side by side and both translate by their own width, so the second copy takes the place of the first and the loop never shows a gap. The copy is aria-hidden. The bar pauses on hover, on keyboard focus and through a visible button, and without motion it falls back to wrapped static text.
<div class="ta-tick">
<div class="ta-tick__view">
<ul class="ta-tick__track">
<li>Free returns within 30 days</li>
<li>Autumn knitwear is in</li>
<li>Studio open Saturdays, 10 to 6</li>
<li>Gift cards from 20 euros</li>
</ul>
<ul class="ta-tick__track ta-tick__track--copy" aria-hidden="true">
<li>Free returns within 30 days</li>
<li>Autumn knitwear is in</li>
<li>Studio open Saturdays, 10 to 6</li>
<li>Gift cards from 20 euros</li>
</ul>
</div>
<button class="ta-tick__toggle" type="button" aria-pressed="false">Pause ticker</button>
</div>
<div class="ta-tick__page" aria-hidden="true"><b>Wool & Ward</b><span>Women</span><span>Men</span><span>Journal</span></div>
<script>
document.querySelectorAll('.ta-tick').forEach(function (root) {
var toggle = root.querySelector('.ta-tick__toggle');
toggle.addEventListener('click', function () {
var paused = toggle.getAttribute('aria-pressed') !== 'true';
toggle.setAttribute('aria-pressed', String(paused));
root.classList.toggle('is-paused', paused);
});
});
</script>
Breathing letter-spacing
letter-spacing
grid stacking
click to start
Breathe inBreathe out
Four seconds in, four seconds out.
Press Start breathing.
A breathing cue for a meditation app. The words widen their letter-spacing as the ring grows and tighten as it shrinks, on an eight-second cycle. Letter-spacing is a layout property, so the words sit in a fixed-size box that cannot push anything else around, and the loop only starts when the user presses Start.
<div class="ta-breath">
<div class="ta-breath__stage">
<span class="ta-breath__ring" aria-hidden="true"></span>
<p class="ta-breath__words"><span class="ta-breath__in">Breathe in</span><span class="ta-breath__out" aria-hidden="true">Breathe out</span></p>
</div>
<p class="ta-breath__hint">Four seconds in, four seconds out.</p>
<button class="ta-breath__toggle" type="button" aria-pressed="false">Start breathing</button>
</div>
<script>
document.querySelectorAll('.ta-breath').forEach(function (root) {
var toggle = root.querySelector('.ta-breath__toggle');
toggle.addEventListener('click', function () {
var on = toggle.getAttribute('aria-pressed') !== 'true';
toggle.setAttribute('aria-pressed', String(on));
root.classList.toggle('is-on', on);
});
});
</script>
Hover and focus text effects
Effects that run because someone pointed at or tabbed to the text. All of them trigger on :focus-visible as well as :hover, and the animated copies are hidden from screen readers so names are read once.
Ink fill on hover
attr() copy
clip-path transition
content alt text
Hover or tab to a link.
Each link has a second copy of its label in ::before, painted in ink and clipped to nothing. Hover or focus opens the clip from left to right, so the dark text seems to pour into the grey letters. The copy uses content: attr(data-text) / "" so screen readers that honor the alt syntax do not read the label twice.
Checking an item draws a hand-drawn SVG line through its text. The path has pathLength="1", so stroke-dasharray: 1 and a dashoffset going from 1 to 0 draw it at any width. The real state lives in the checkbox, which a screen reader announces, and the scribble is decoration.
Hovering or focusing the button sends a single wave through its letters: each span rises and falls with a delay of 30 ms times its index. The animation is attached to the hover state, so it runs once per hover instead of looping. The link is named by aria-label and the letter spans are aria-hidden.
Channel 7 went offline at 21:14. We will reconnect as soon as the stream is back.
Hover the card or tab to the button.
Two copies of the title in ::before and ::after jump between clipped slices in magenta and cyan for two short bursts when the card is hovered or the button gets focus. The slices change about three times per second, which stays at the WCAG 2.3.1 limit for flashes, and the effect stops on its own.
<div class="ta-glitch">
<h4 class="ta-glitch__title" data-text="Signal lost">Signal lost</h4>
<p class="ta-glitch__text">Channel 7 went offline at 21:14. We will reconnect as soon as the stream is back.</p>
<button class="ta-glitch__retry" type="button">Retry connection</button>
</div>
Numbers, boards and scroll-linked text
Text animations that carry information: a counter driven by a registered custom property, a flip board, a highlighter that marks the key sentence, and a scroll-driven reveal that needs no script at all.
Counting numbers with @property
@property <integer>
counter()
tabular-nums
Projects shipped
128
On-time delivery
97%
Countries served
36
Registering --ta-n as an integer lets the browser interpolate it, and counter-reset plus counter() turns the value into visible text, so the stats count up with no script in the animation. The count is aria-hidden and the real number sits in a visually hidden span. Without @property the final number simply shows.
Good typography is invisible until it fails. Readers notice a cramped line height long before they notice a good one, and most of them simply leave.
From the house style guide at Fieldnote Press
The key sentence is a mark element with a flat yellow band painted as a background image (a single-color gradient, used only because background-size needs an image). Growing background-size from 0 to 100% draws the band, and because inline backgrounds are sliced across line boxes, it travels line by line like a real marker.
<figure class="ta-mark">
<blockquote class="ta-mark__quote"><p>Good typography is invisible until it fails. <mark class="ta-mark__hl">Readers notice a cramped line height long before they notice a good one,</mark> and most of them simply leave.</p></blockquote>
<figcaption class="ta-mark__cite">From the house style guide at Fieldnote Press</figcaption>
<button class="ta-mark__replay" type="button">Replay</button>
</figure>
<script>
document.querySelectorAll('.ta-mark').forEach(function (root) {
function play() {
root.classList.remove('is-play');
void root.offsetWidth;
root.classList.add('is-play');
}
root.classList.add('ta-js');
if ('IntersectionObserver' in window) {
new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) { play(); }
});
}, { threshold: 0.6 }).observe(root);
} else {
play();
}
root.querySelector('.ta-mark__replay').addEventListener('click', play);
});
</script>
Split-flap departure board
rotateX flip
staggered cells
aria-live
DestinationLISBON
Time14:05
GateB12
Lisbon, 14:05, gate B12
Press Next departure.
Every character is its own cell with a hinge line across the middle. When the data changes, only the cells whose letter changed flip in with rotateX from 90 degrees, each a little later than the one before. The cells are aria-hidden and a polite live region announces the whole row once.
<div class="ta-flap">
<div class="ta-flap__board" aria-hidden="true">
<div class="ta-flap__field"><span class="ta-flap__label">Destination</span><span class="ta-flap__cells" data-size="7"><b>L</b><b>I</b><b>S</b><b>B</b><b>O</b><b>N</b><b> </b></span></div>
<div class="ta-flap__field"><span class="ta-flap__label">Time</span><span class="ta-flap__cells" data-size="5"><b>1</b><b>4</b><b>:</b><b>0</b><b>5</b></span></div>
<div class="ta-flap__field"><span class="ta-flap__label">Gate</span><span class="ta-flap__cells" data-size="3"><b>B</b><b>1</b><b>2</b></span></div>
</div>
<p class="ta-flap__sr" aria-live="polite">Lisbon, 14:05, gate B12</p>
<button class="ta-flap__next" type="button">Next departure</button>
</div>
<script>
document.querySelectorAll('.ta-flap').forEach(function (root) {
var rows = [['LISBON', '14:05', 'B12'], ['OSLO', '14:20', 'C03'], ['KYOTO', '14:35', 'A07'], ['QUITO', '14:50', 'D11'], ['DAKAR', '15:05', 'B02']];
var index = 0;
var fields = root.querySelectorAll('.ta-flap__cells');
var live = root.querySelector('.ta-flap__sr');
root.querySelector('.ta-flap__next').addEventListener('click', function () {
index = (index + 1) % rows.length;
var row = rows[index];
var step = 0;
fields.forEach(function (field, f) {
var size = Number(field.getAttribute('data-size'));
var text = row[f];
while (text.length < size) { text += ' '; }
field.querySelectorAll('b').forEach(function (cell, c) {
if (cell.textContent !== text[c]) {
cell.textContent = text[c];
cell.style.setProperty('--ta-i', String(step));
cell.classList.remove('is-flip');
void cell.offsetWidth;
cell.classList.add('is-flip');
}
step += 1;
});
});
live.textContent = row[0].charAt(0) + row[0].slice(1).toLowerCase() + ', ' + row[1] + ', gate ' + row[2];
});
});
</script>
Scroll-driven paragraph reveal
animation-timeline: view()
animation-range
@supports
On quiet interfaces
The best reading apps share one habit. Nothing on the page moves while your eyes are on the words.
Motion belongs at the edges: a paragraph settling in as it arrives, a heading that finds its place, then stillness.
Tie that motion to the scrollbar and the reader stays in charge. Stop scrolling and the page stops with you.
Scroll back up and every paragraph is already finished. No replay, no waiting, no surprise.
That is the whole trick. The effect only ever answers something the reader just did.
Scroll inside the box. Needs a browser with scroll-driven animations.
Each paragraph runs its fade and lift against a view() timeline, so progress follows its position in the scroll box instead of the clock. animation-range: entry limits the effect to the moment a paragraph enters, and everything sits inside @supports (animation-timeline: view()), so browsers without scroll-driven animations show plain text.
<div class="ta-scroll" tabindex="0" role="region" aria-label="Article preview, scrollable">
<h4 class="ta-scroll__title">On quiet interfaces</h4>
<p class="ta-scroll__p">The best reading apps share one habit. Nothing on the page moves while your eyes are on the words.</p>
<p class="ta-scroll__p">Motion belongs at the edges: a paragraph settling in as it arrives, a heading that finds its place, then stillness.</p>
<p class="ta-scroll__p">Tie that motion to the scrollbar and the reader stays in charge. Stop scrolling and the page stops with you.</p>
<p class="ta-scroll__p">Scroll back up and every paragraph is already finished. No replay, no waiting, no surprise.</p>
<p class="ta-scroll__p">That is the whole trick. The effect only ever answers something the reader just did.</p>
</div>
10 CSS Text Animations You Can Copy Today · WPDean
How do CSS text animations work?
Stylesheet rules alone can move, fade, recolor or clip text over time, and no JavaScript animation loop is involved.
A keyframes rule describes the states the text passes through. The animation property then attaches that rule to an element.
According to MDN, the animation shorthand bundles name, duration, timing function, delay, iteration count, direction, fill mode and play state. Text effects lean hardest on a handful of those.
Timing function sets the feel. cubic-bezier() curves give smooth motion, and steps() gives the frame-by-frame jumps a typewriter needs. Delay offsets the start, which is how staggered text works. Loops like shimmer and marquee use an iteration count of infinite, while one-offs get a fixed number.
Alternate direction plays forward and then back, so it suits a pulsing glow. Fill mode set to both holds the first keyframe during the delay and the last one afterward, which means text never flashes visible or snaps back.
Transitions cover the simplest case. They run between two states when something changes, such as :hover or a class toggle, and reverse on their own.
Keyframe animations start without a state change, hold any number of steps and can loop. Once text needs more than a start and an end, keyframes are the only option.
CSS has no selector for individual characters (only ::first-letter and ::first-line), so per-letter effects need every character wrapped in its own span. That limit shapes most of what follows.
Which text properties can you animate?
Any property with a numeric or color value can be animated. For smooth text motion, though, web.dev’s guidance from the Chrome team points to transform and opacity, because browsers can keep those on the compositing stage.
Property
What it does to text
Cost class
Best use
transform (translate, scale, rotate)
Moves, resizes or tilts text
Compositor
Slide-ins, wave, marquee
opacity
Fades text in or out
Compositor
Fade-ins
color, text-shadow, background-position
Recolors, adds glow, shifts a gradient
Paint
Glow, shimmer
letter-spacing, width, font-size
Reflows the surrounding text
Layout
Typewriter, hover tracking
clip-path
Cuts away part of the text box
Profile first
Wipe reveals
font-weight (variable fonts)
Thickens or thins glyphs
Layout
Hover and breathing effects
Glow pulses animate the blur radius or color of text-shadow. A CSS text shadow generator speeds up building the start and end values.
Gradients are the odd case. A gradient is an image value, so its color stops do not interpolate, and a plain custom property fails too because the browser reads it as a string.
Registering the property with @property fixes that. The rule declares a type and an initial value, and says whether the value inherits. After that the browser can animate it, and MDN lists it as Baseline 2024 (newly available).
The older workaround skips registration. Paint an oversized gradient and animate background-position. Most shimmer effects are built that way.
CSS text animation effects and how each is built
The table compares typewriter, fade, slide, shimmer, reveal, glitch, wave and marquee. The cost and fallback columns are the ones worth reading first.
Effect
Core properties
Cost class
Main failure mode
Reduced-motion fallback
Typewriter
width, steps(), border caret
Layout
Fixed character count, wraps break
Show full text at once
Fade and slide in
opacity, transform
Compositor
Base opacity 0 hides text if the animation fails
Keep the fade, drop the slide
Gradient shimmer
background-position, background-clip
Paint
Invisible text without a fallback color
Static gradient or solid color
Text reveal
transform or clip-path
Compositor or profile first
Descenders get clipped
Plain fade
Glitch
text-shadow, pseudo-elements, transform
Paint
Flicker hurts readability
Static text
Wave
transform per character
Compositor
Split spans confuse screen readers
Static text
Marquee
transform (translateX)
Compositor
Endless loop, no pause control
Static, wrapped text
For a headline, a fade with a short upward slide beats a glitch or wave in most cases. It costs almost nothing and falls back to plain text without any drama.
Typewriter and blinking cursor
The classic typewriter animates width from 0 to the full text width with steps(), and the step count equals the number of characters.
The caret is just a right border that blinks through its own step-end animation.
You need a known character count and a monospace font, or the ch unit stops matching the text width. Wrapped lines break it too, since white-space: nowrap forces a single line.
Width is a layout property. That is fine for one short headline and a poor choice for paragraphs.
Fade-in and slide-in
These effects pair opacity with translateY or translateX, which keeps them cheap and predictable.
Set the fill-mode to both. Without it, delayed text flashes visible before the animation starts.
Short travel distances read better than long ones. Half an em is plenty.
Gradient shimmer
Start with a linear-gradient background sized at 200% width. Add background-clip: text, plus the -webkit- prefixed version for older engines, and set -webkit-text-fill-color: transparent so the gradient shows through the letters. Then animate background-position from 0 to 200%, looping with linear timing.
Set a solid color as the base style, then wrap the clip and the transparent fill in @supports (background-clip: text), so an unsupported browser keeps readable text instead of invisible text.
The gradient must start and end on the same color, or the loop shows a seam.
Text reveal with clip-path or overflow hidden
The mask method wraps the text in a box with overflow: hidden and slides the inner span up from translateY(100%). Only transform animates.
The clip-path method animates clip-path: inset() from fully clipped to fully open, which gives wipes from any direction.
For shapes beyond inset(), such as diagonal wipes, a clip-path generator saves the trial and error. Clip-path is not on web.dev’s compositor-safe list, so profile it before putting it on a large hero.
The mask method wins for repeated headlines. Give the overflow box extra padding so descenders on g, y and p are not cut off.
Glitch and wave
A glitch effect stacks two pseudo-element copies of the text (content: attr(data-text)) over the original, offsets them a few pixels in contrasting colors and jitters them with steps() timing. An animated glitch text generator is a quick way to preview the timing before hand-coding it.
A wave moves each character up and down with translateY, offset by an index-based animation-delay. It needs per-letter spans, built the way the next section describes.
Both are motion-heavy. Glitch flickers and wave loops forever, and neither holds up on body text. Honestly, I’d skip them anywhere people have to read.
Marquee and scrolling text
The old marquee element is obsolete, so scrolling text now means a CSS animation.
Duplicate the content inside a track, translate the track from 0 to -50% with linear timing and an infinite iteration count, and the loop restarts without a visible jump.
Pausing on hover takes animation-play-state: paused. Because the whole thing runs on transform, it stays cheap.
How to animate text letter by letter
Wrap each character in a span, give each span an index as a custom property, then compute animation-delay from that index with calc(). CSS cannot select single characters, so the wrapping needs JavaScript or a build step.
Splitting.js and GSAP SplitText both do the wrapping. Splitting.js adds a –char-index custom property to every character span, which is all the stagger needs.
Split the text: put data-splitting on the element and call Splitting(), or use SplitText.create() from GSAP.
Read the index from –char-index on each span, so nothing is counted in CSS.
Reuse a short keyframes rule, such as the rise animation from the fade-in effect.
Apply the stagger as the animation-delay, and set display: inline-block on each span because transforms ignore inline boxes.
Label the parent with aria-label and mark the spans aria-hidden.
Screen readers can announce a run of single-character spans as separate letters. The fix is an aria-label on the parent that carries the full string (an ARIA attribute built for this), plus aria-hidden on the spans.
GSAP’s SplitText documentation says its default aria setting, “auto”, builds that label from the element’s textContent and hides the character elements. SplitText has been free, commercial use included, since GSAP 3.13.
Not everyone accepts that as a fix. Adrian Roselli’s February 2026 write-up argues that aria-label is not allowed on every element role and may not be translated for users, reports that screen readers fail to expose SplitText output, and concludes that words should not be split into letters at all.
My rule is to animate words, not letters, unless the letter-level motion is the point.
Separate spans break pair kerning and ligatures such as fi. Arabic letters lose their joins when each one sits in an inline-block, and a 300-character paragraph becomes 300 animated nodes.
Triggering text animations on hover, page load and scroll
By default a text animation starts on page load. Hover goes through :hover, a class toggle comes from JavaScript, and scroll uses animation-timeline.
Load and hover triggers
On page load the animation runs the moment the styles apply, and animation-delay sequences several headings.
Hover is fussier. A transition on the base state reverses smoothly when the pointer leaves, while an animation on :hover snaps back. Class toggles usually arrive through IntersectionObserver, which reports when an element crosses into the viewport.
Hover has one trap. If the text moves out from under the cursor, the hover state ends and the effect flickers, so attach :hover to the parent and animate the child.
Buttons, cards and links follow the same transition logic, and a set of CSS hover effects shows how far it stretches.
Scroll-driven text with animation-timeline
MDN’s browser-compatibility data lists scroll-driven animations as shipping in Chrome and Edge 115 (July 2023), Safari 26 and Firefox 159, which is scheduled for October 27, 2026. Earlier Firefox versions keep them behind a preference flag, so Firefox is the one to recheck on MDN or Can I use before launch.
Scroll-driven animations swap time for scroll progress. The scroll() function ties progress to a scroller’s position, and view() ties it to the element’s own visibility.
Declare animation-timeline after the animation shorthand, because the shorthand resets it. The default range is cover, so entry 0% entry 100% finishes the reveal as the text enters instead of waiting until it has crossed the whole viewport.
Progress follows the scrollbar, so a headline can stall at 40 percent opacity when the reader stops scrolling. A short entry range keeps the text from spending long half-visible.
Keep the always-visible state in the base CSS and layer the animation inside @supports. Browsers without support skip the block, and the text simply shows.
Progress bars and parallax use the same timeline properties, and a broader guide to CSS scroll effects covers them.
Browser support for text animation features
Keyframe animations, transitions and prefers-reduced-motion work in every major browser. Scroll-driven animations are the exception, and Firefox is the last engine to ship them.
MDN’s Firefox 159 release notes say scroll-driven animations are now enabled by default, in a release scheduled for October 27, 2026. Until then they sat behind a preference, and the Firefox 152 release notes (June 16, 2026) still added scroll-driven pieces, such as range names inside @keyframes, behind that flag.
Some 2026 blog posts already called the feature Baseline or consistent across all major browsers. That was early, because web.dev defines Baseline as support across Chrome, Edge, Firefox and Safari, and the web-features data still marked it as not Baseline before Firefox 159.
web.dev puts the wait between a feature becoming newly available and reaching Baseline widely available at 30 months. MDN says prefers-reduced-motion became available across browsers in January 2020 (Safari 10.1, Firefox 63 and Chrome 74 shipped it earlier, and Edge 79 completed the set).
As of October 2026, treat scroll-driven text as progressive enhancement, since Firefox users on older versions will not see it. Test Firefox and Safari on real devices, because a cross-browser compatibility pass catches the gaps a support table misses.
How to keep CSS text animations smooth
web.dev’s guide to animations and performance also settles the CSS versus JavaScript question. When an animation triggers layout or paint, that overhead dwarfs any difference between the two.
The width-based typewriter shows how that plays out. Width is a layout property, but steps(24) over two seconds changes it only 24 times, so a single short line stays cheap.
Run the same trick on a paragraph and every step re-lays out a larger block.
Good CLS is 0.1 or less, measured at the 75th percentile of page loads, according to web.dev.
A layout shift happens when an existing element changes its start position. A text animation that changes width, height or letter-spacing can push neighbors around and count against that score.
Give the text container its final size before the animation starts. web.dev’s CLS guide recommends transform: scale() over changing width and height. Animating letter-spacing on a heading rewraps the line and moves everything below it, so watch for that one in particular.
MDN calls will-change a last resort for performance problems that already exist. Its warnings are to skip it on too many elements and to skip it as premature optimization.
A headline split into 60 character spans is the too-many-elements case. If profiling does show a stutter at the start of an effect, set the hint from script just before it runs, since MDN recommends scripting over a permanent stylesheet rule in most cases.
In Chrome DevTools, the Performance panel is where to record while the animation runs and look for layout work on the main thread, as web.dev’s animation guide walks through. Paint flashing highlights regions that repaint every frame.
The Animations panel slows down, replays and edits timing, delay and keyframe offsets live, for CSS animations, transitions and web animations.
Accessibility rules for animated text
Respect prefers-reduced-motion. Give any auto-starting motion that runs past five seconds a way to stop, and keep flashing at three flashes per second or fewer.
Motion triggers real symptoms. W3C’s guidance says people can get sick from motion effects, which is why web accessibility standards cover animation at all.
The prefers-reduced-motion feature is a CSS media query that reads the operating system setting. MDN describes the preference as a request to remove, reduce or replace motion, not to delete every effect.
The fade stays and the slide goes. Typewriter and shimmer drop to their finished state.
WCAG 2.2.2 (Level A) requires a pause, stop or hide mechanism for moving, blinking or scrolling content that starts automatically, lasts more than five seconds and sits beside other content, unless the movement is necessary to the activity, according to W3C’s Understanding document.
Every infinite loop above qualifies, so the marquee track and the shimmer count, and so does the typewriter caret. The caret runs 0.6 seconds per cycle with no end, so an iteration count of 8 stops it at 4.8 seconds, inside the limit.
A marquee needs a visible button that sets animation-play-state: paused. Shimmer needs a finite iteration count that ends inside five seconds.
Motion that starts from hovering or scrolling still counts as automatic in W3C’s WCAG 2.2 Understanding document for 2.2.2, so a long looping hover effect needs a stop as well. WCAG 2.3.3 (Level AAA) targets interaction-triggered motion directly and lists the reduced-motion media query as technique C39.
WCAG 2.3.1 (Level A) allows no more than three flashes in any one-second period, unless the flash stays below the general and red flash thresholds. Glitch jitter is the effect most likely to cross that line, so slow its cycle or run it a few times only.
There’s some disagreement here. Hidde de Vries has argued that prefers-reduced-motion can count toward 2.2.2, but he says he is torn on whether it is sufficient. W3C files the media-query technique under 2.3.3 rather than 2.2.2, so a visible control remains the safer route.
When should you use JavaScript instead of CSS for text animation?
CSS handles short, fixed text animated on load or scroll position. JavaScript earns its place when the text length is unknown, or when a script has to pause, seek or reverse the animation. Effects that must run in sequence across several elements push the same way.
Approach
Best for
Main limit
CSS keyframes
Short fixed text, load and scroll effects
Text length must be known, no line selector
Web Animations API
Script control over play, pause and reverse
Needs JavaScript
GSAP with SplitText
Sequenced timelines, character, word and line splits
Adds a dependency
Typed.js
Strings of unknown length, backspacing
JavaScript only
Animate.css
Prebuilt effects through classes
Fixed set of effects
The CSS typewriter hard-codes the steps() count and the ch width. One line of script can set a –chars custom property from the text, and the CSS reads it.
CSS cannot select lines of text, only the first one. GSAP’s SplitText documentation lists line splitting and responsive re-splitting on resize, which is the case where a library earns its place.
For longer sequences, JavaScript text animation techniques bring timelines and callbacks, and let you change things at runtime.
The built-in route is element.animate() from the Web Animations API, which MDN lists as Baseline widely available, with support across browsers since March 2020.
Typed.js handles strings of any length, with typing speed, backspacing and looping as options. Its README lists GitHub’s package registry page among the sites that have used it, and it describes a mode that reads strings from static HTML, which keeps the text in the page source.
Animate.css belongs to the family of CSS animation libraries. Add the animate__animated class plus an effect name and the keyframes are already written. The cost is size, since the unminified animate.css 4.1.1 file weighs 93.1 KiB (95,374 bytes), though a custom build trims it.
The project’s accessibility docs say the stylesheet supports prefers-reduced-motion out of the box.
One headline that fades in does not justify a library. Pull one in once the text or the sequencing outgrows what keyframes can express.
CSS text animations FAQ
How do you loop or reverse a text animation?
Set animation-iteration-count to infinite for a loop, or to a number for a fixed run. Animation-direction takes reverse to play backward and alternate to bounce forward, then back.
Fractional counts such as 2.5 stop the animation halfway through a cycle.
How can you replay a text animation without reloading the page?
A CSS animation runs once, when the animation property first applies. To replay it, remove the class, force a reflow by reading element.offsetWidth, then add the class back.
The alternative is calling element.animate() from the Web Animations API, which creates a fresh animation on every call.
Does animated text hurt SEO?
Not by itself. A keyframe animation leaves the markup untouched, so the text stays in the page source.
The risk sits in how the text gets there. Letter spans and script-typed strings change the DOM, so keep the full string in the HTML.
What duration and easing suit headline animations?
Roughly 400 to 800 ms with ease-out lets a headline decelerate into place without dragging. For scale, HTTP Archive’s 2020 Web Almanac measured a median transition duration of 300 ms on desktop and 250 ms on mobile, though that covers interface transitions, not text effects.
Typewriter effects run longer, since each character needs time to register.
Which Text Effect to Ship First
Among CSS text animations, a fade-in with a short upward slide goes live first, because the keyframes hold only a from state and the text stays readable if the animation never runs.
Ship the transform-and-opacity fade, then write the reduced-motion fallback. Testing in Firefox, Safari and Chrome DevTools comes last.
The fallback comes before any second effect because every added effect needs its own fallback, and the cases multiply with each one.
What you give up is spectacle. Glitch and wave stay off the page until the baseline passes review.
Revisit the scroll-driven option once Firefox 159, which enables animation-timeline by default, has reached most of your Firefox visitors. This position was verified in October 2026.