Split the text into pieces
CSS cannot animate one word of a text node on its own, so each word needs its own element. A small function replaces the text with one span per word and writes each word’s place in the order into a custom property, --i, for the CSS to multiply.
// Wrap each word in a span that knows its place (--i), hide the
// pieces from screen readers, and give them one plain copy instead.
function splitWords(el) {
const text = el.textContent.trim();
const words = text.split(/\s+/);
el.textContent = '';
words.forEach((word, i) => {
const span = document.createElement('span');
span.className = 'word';
span.textContent = word;
span.style.setProperty('--i', i);
span.setAttribute('aria-hidden', 'true');
el.append(span, i < words.length - 1 ? ' ' : '');
});
el.style.setProperty('--n', words.length);
const copy = document.createElement('span');
copy.className = 'visually-hidden';
copy.textContent = text;
el.append(copy);
}.word,
.char {
display: inline-block;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}The spans are inline-block because translate does not move an inline box. Each one is aria-hidden, and one visually hidden copy of the whole sentence goes at the end, so a screen reader reads “Words arrive one at a time” once, not six separate words. Why a hidden copy and not an aria-label on the heading is in split-text animation a screen reader can read.
Reveal the words when the heading scrolls in
Start each word hidden and a little low, and let a transition-delay of --i × 60 ms stagger them. Adding one class to the heading then plays the whole sentence, as Fig. 1 shows. Everything that hides is inside @media (prefers-reduced-motion: no-preference), so readers who ask for less motion get the heading as it is.
<h2 class="reveal-text">Words arrive one at a time</h2>@media (prefers-reduced-motion: no-preference) {
.reveal-text .word {
opacity: 0;
translate: 0 0.6em;
transition: opacity 0.5s ease-out, translate 0.5s ease-out;
/* Word i waits i × 60ms. */
transition-delay: calc(var(--i) * 60ms);
}
.reveal-text.is-in .word {
opacity: 1;
translate: 0 0;
}
}An IntersectionObserver adds that class when the heading comes into view, and then stops watching it so it plays once. The negative bottom margin waits until the heading is 15% of the screen above the bottom edge, so the reader sees it happen rather than catching the end of it.
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
entry.target.classList.add('is-in');
observer.unobserve(entry.target); // play once
}
}, { rootMargin: '0px 0px -15% 0px' }); // once it is 15% above the bottom
for (const el of document.querySelectorAll('.reveal-text')) {
splitWords(el);
observer.observe(el);
}The same pattern reveals whole blocks: cards, images, paragraphs. If that is what you are after, the scroll reveal generator builds it with presets, and the AOS animation examples show all 27 of AOS’s data-aos effects running, with the markup.
Letter by letter
Splitting into letters works the same way, with two traps. Side by side as inline-blocks, a line can break between any two letters, in the middle of a word; wrapping each word’s letters in a box that does not wrap keeps words whole. And splitting a string with split(''), or even by code point, cuts an emoji with a skin tone or a flag in two. Intl.Segmenter splits by what a reader sees as one character.
const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
function splitChars(el) {
const text = el.textContent.trim();
const words = text.split(/\s+/);
el.textContent = '';
let i = 0;
words.forEach((word, w) => {
// Each word is one box that does not wrap, so a line never breaks mid-word.
const box = document.createElement('span');
box.className = 'word';
box.style.whiteSpace = 'nowrap';
box.setAttribute('aria-hidden', 'true');
// By grapheme: an emoji with a skin tone, or a flag, stays one piece.
for (const { segment } of graphemes.segment(word)) {
const span = document.createElement('span');
span.className = 'char';
span.textContent = segment;
span.style.setProperty('--i', i++);
box.append(span);
}
el.append(box, w < words.length - 1 ? ' ' : '');
});
el.style.setProperty('--n', i);
const copy = document.createElement('span');
copy.className = 'visually-hidden';
copy.textContent = text;
el.append(copy);
}@media (prefers-reduced-motion: no-preference) {
.reveal-chars .char {
opacity: 0;
translate: 0 0.6em;
transition: opacity 0.4s ease-out, translate 0.4s ease-out;
transition-delay: calc(var(--i) * 25ms);
}
.reveal-chars.is-in .char {
opacity: 1;
translate: 0 0;
}
}Give the heading the class reveal-chars, and observe it with the same observer:
for (const el of document.querySelectorAll('.reveal-chars')) {
splitChars(el);
observer.observe(el);
}Keep the delay per letter small. At 25 ms, a 40-letter heading takes a second just to start its last letter, which is about as long as anyone waits for a heading.
Follow the scroll, in CSS only
The reveal above plays on a clock once it starts. To tie the text to the scroll instead, so it lights up as the reader scrolls and dims if they scroll back, use the same split and a scroll timeline, with no observer. The heading names a view() timeline, and each word takes its own slice of it, worked out from --i and the word count --n that splitWords set:
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
.scrub-text { view-timeline: --text; }
.scrub-text .word {
animation: light-up linear both;
animation-timeline: --text;
/* Word i of n lights up in its own slice of the crossing. */
animation-range:
cover calc(20% + 30% * var(--i) / var(--n))
cover calc(25% + 30% * var(--i) / var(--n));
}
}
}
@keyframes light-up {
from { opacity: 0.15; }
to { opacity: 1; }
}The first word lights up between 20% and 25% of the heading’s crossing and the last ends before 55%, around the moment the heading’s centre passes the centre of the screen. Browsers without scroll timelines skip the block and show the text as it is. How view() and the cover range work is in CSS scroll-driven animations explained, and the text fill on scroll generator builds this effect, and a colour fill, with the code to copy.
The same with a library
svg-scroll-draw’s scrollText does the split, the screen-reader copy and the stagger in one call, by word, letter or line, and keeps line splits correct when the width changes.
import { scrollText } from 'svg-scroll-draw/text';
// Splits by letter, adds the screen-reader copy, and brings each letter
// up 24px from transparent as the heading moves through the window.
scrollText('.headline', { split: 'chars', stagger: 0.03 });It is scrubbed by the scroll: the text starts as the heading’s top passes 85% of the way down the screen and is finished when it reaches 40%, and with once, on by default, it stays finished when the reader scrolls back. Each piece starts transparent and 24px low unless you pass from. Under reduced motion it shows the text finished, also when the setting changes while the page is open. The text animation generator lets you pick the split and the effect, watch it, and copy the call or a React component; for text that types itself instead, there is the typewriter effect generator.
— / next
Pick a split, copy the code
Choose words, letters or lines and how each piece arrives, scroll the preview, and copy the JavaScript or a React component.
Star on GitHubFound this useful? A star helps others find it.