15 / blog / how-toNovember 20267 min read

How to animate text on scroll with CSS and JavaScript

A heading that arrives word by word is three small pieces: a script that splits the text into spans, CSS that staggers them, and an observer that starts it when the heading scrolls in. Here are all three, a letter-by-letter version, a pure-CSS one that follows the scroll, and the plain copy a screen reader needs.

  • How-To
  • split text
  • IntersectionObserver
  • Scroll effects
Split by
word · letter
Screen readers
one plain copy
scrollText
3.9 KB
Words --i: 0
arrive --i: 1
one --i: 2
at --i: 3
a --i: 4
time --i: 5
Screen reader“Words arrive one at a time”, one copy, never hidden
Fig. 1 — what the reveal CSS below does once the heading scrolls in: each word waits its place times 60 ms, then takes 0.5 s to arrive. A screen reader reads the plain copy, which never moves.
01Step 1

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.

split.js21 lines
// 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);
}
split.css13 lines
.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.

02Step 2

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.

index.html1 line
<h2 class="reveal-text">Words arrive one at a time</h2>
reveal.css13 lines
@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.

reveal.js12 lines
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.

03Variant

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.

split-chars.js30 lines
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);
}
reveal-chars.css12 lines
@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:

reveal-chars.js4 lines
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.

04Pure CSS

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:

scrub.css19 lines
@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.

05svg-scroll-draw

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.

headline.js5 lines
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.

→Parts list

Keep reading

More from the blog