03 / blog / accessibilityNovember 20265 min read

Split-text animation a screen reader can read

Animating a headline word by word means wrapping every word in its own element. The usual way to keep that readable puts an aria-label on the paragraph, and ARIA does not allow that on a paragraph. Here is why it can leave a screen reader with nothing, and the pattern that holds up.

  • Accessibility
  • scrollText
  • ARIA
  • Screen readers
ARIA 1.2
§5.2.8.6
Axe rule
aria-prohibited-attr
scrollText
3.4 KB
01The split

Splitting text changes what is read

To move each word on its own, a script replaces the text with one element per word (or per character, or per line). The page looks the same; the document does not.

index.html3 lines
<p class="headline">
  <span>Ship</span> <span>real</span> <span>browser</span> <span>tests</span>
</p>

A screen reader can now meet four separate pieces instead of one sentence. With a character split it is worse: one element per letter, and some readers spell the headline out. So the pieces get hidden from assistive technology, and the sentence has to be put back somewhere it will be read.

02The usual fix

The usual fix: an aria-label on the parent

The common answer hides every piece with aria-hidden and gives the whole sentence to the parent as its aria-label. It is what GSAP’s SplitText does by default (its aria: "auto" option), and what svg-scroll-draw’s scrollText did until 2.21.0.

index.html6 lines
<p class="headline" aria-label="Ship real browser tests">
  <span aria-hidden="true">Ship</span>
  <span aria-hidden="true">real</span>
  <span aria-hidden="true">browser</span>
  <span aria-hidden="true">tests</span>
</p>

On a heading or a button it works: those elements can carry a name. On a <p>, a <span> or a <div> it does not.

03ARIA 1.2

Why it breaks on a paragraph

WAI-ARIA 1.2 lists eleven roles that cannot be named (section 5.2.8.6). Two of them are generic, which every <div> and <span> is, and paragraph. On those, authors must not use aria-label or aria-labelledby: the label is not part of what the element is allowed to say.

A browser or screen reader is therefore free to leave the label out. If it does, the pattern above reads nothing at all, because every word that is on the page carries aria-hidden. axe reports it as aria-prohibited-attr, with an impact of serious.

04The copy

The fix: one visually hidden copy

Keep the pieces hidden, and put the sentence back as ordinary text: one span after the pieces, positioned off the layout and clipped to nothing, the same technique as a .sr-only class. A screen reader reads it as part of the paragraph, whatever element the paragraph is.

BeforeA label the paragraph may not have

before.html6 lines
<p class="headline" aria-label="Ship real browser tests">
  <span aria-hidden="true">Ship</span>
  <span aria-hidden="true">real</span>
  <span aria-hidden="true">browser</span>
  <span aria-hidden="true">tests</span>
</p>

AfterText every role reads

after.html7 lines
<p class="headline">
  <span aria-hidden="true">Ship</span>
  <span aria-hidden="true">real</span>
  <span aria-hidden="true">browser</span>
  <span aria-hidden="true">tests</span>
  <span class="sr-only">Ship real browser tests</span>
</p>

The whole splitter, with the copy built in:

split-words.js18 lines
function splitWords(el) {
  const text = el.textContent;
  el.textContent = '';
  for (const chunk of text.split(/(\s+)/)) {
    const piece = document.createElement('span');
    piece.setAttribute('aria-hidden', 'true');
    piece.textContent = chunk;
    if (chunk.trim()) piece.style.display = 'inline-block';
    el.append(piece);
  }
  // The whole sentence once, for screen readers: on the page, not on screen.
  const copy = document.createElement('span');
  copy.textContent = text;
  copy.style.cssText =
    'position:absolute;width:1px;height:1px;overflow:hidden;' +
    'clip-path:inset(50%);white-space:nowrap;user-select:none';
  el.append(copy);
}
  • user-select: none on the copy keeps a reader who selects the headline from copying it twice.
  • el.textContent now holds the sentence twice: the pieces and the copy. Read the text before you split it if you need it later.
  • Links or other markup inside the text are flattened by a word split either way. GSAP’s docs say the same of their default and suggest a screen-reader-only copy for that case too.
05Verify

Check it in a real browser

Two checks catch it. axe flags an aria-label on a paragraph wherever it runs. And the browser’s accessibility tree shows what a screen reader is actually given: Playwright reads it with ariaSnapshot(), and the sentence should appear exactly once.

split.spec.ts3 lines
// Playwright: what the browser hands assistive technology.
const tree = await page.locator('.headline').ariaSnapshot();
expect(tree.split('Ship real browser tests').length - 1).toBe(1);

svg-scroll-draw runs that test in Chromium, Firefox and WebKit, and a mutation test removes the copy to prove the test fails without it.

06scrollText

With svg-scroll-draw

scrollText splits by words, characters or lines and reveals the pieces as the page scrolls. Since 2.21.0 it adds the visually hidden copy itself and sets no aria-label; destroy() puts the original markup back. Under reduced motion it shows the final text straight away.

headline.js3 lines
import { scrollText } from 'svg-scroll-draw/text';

scrollText('.headline', { split: 'words', stagger: 0.05 });
Headline.tsx5 lines
import { ScrollText } from 'svg-scroll-draw/react';

<ScrollText as="p" split="words" className="headline">
  Ship real browser tests
</ScrollText>
→Parts list

Keep reading

More from the blog