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.
<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.
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.
<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.
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.
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
<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
<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:
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: noneon the copy keeps a reader who selects the headline from copying it twice.el.textContentnow 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.
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.
// 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.
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.
import { scrollText } from 'svg-scroll-draw/text';
scrollText('.headline', { split: 'words', stagger: 0.05 });import { ScrollText } from 'svg-scroll-draw/react';
<ScrollText as="p" split="words" className="headline">
Ship real browser tests
</ScrollText>