Testing Live Region Announcements
When content changes without a page load — an item added to a basket, a form saved, search results updated, an error after submission — sighted users see the change and screen-reader users hear nothing, unless the change happens inside a live region. Live regions are how an interface speaks, and they fail in ways that are invisible to everyone who is not using assistive technology: a region created at the same moment as its message, so nothing is announced; an assertive alert for a trivial update, interrupting the user; a message that never changes text, so the second identical announcement is swallowed. This guide covers testing those behaviours in Vitest with Testing Library — the right roles and politeness, regions that exist before they are updated, messages that actually change, and announcements that are cleared — and where a real screen reader is still needed. It sits under accessibility testing for components.
Root Cause Analysis
The most common live-region bug is timing. Screen readers observe changes to regions they already know about; a region inserted into the DOM already containing its message is often not announced, because from the assistive technology’s point of view there was no change, only a new element. Components that render {error && <p role="alert">{error}</p>} fall into this trap for some screen readers, while a region rendered permanently and filled when needed works reliably.
The second is politeness. role="alert" and aria-live="assertive" interrupt whatever the screen reader is saying; role="status" and aria-live="polite" wait for a pause. Using assertive for routine confirmations — “Saved”, “3 results” — makes an interface exhausting to use, while using polite for a blocking error can leave the user unaware of it until much later. The choice is a design decision that should be pinned by a test.
The third is repetition. Setting a region’s text to the same string twice in a row — “Item added” after adding two items — produces no DOM change the second time, so nothing is announced. Users hear the first addition and not the second. A test that performs the action twice and checks the message changed catches this; one that performs it once cannot.
Reproducible Setup
A small announcer used by the whole application — one polite and one assertive region, always rendered, with a way to post messages.
// src/a11y/Announcer.tsx
const AnnouncerContext = createContext<(msg: string, level?: 'polite' | 'assertive') => void>(() => {});
export function AnnouncerProvider({ children }: { children: ReactNode }) {
const [polite, setPolite] = useState('');
const [assertive, setAssertive] = useState('');
const counter = useRef(0);
const announce = useCallback((msg: string, level: 'polite' | 'assertive' = 'polite') => {
counter.current++;
// A zero-width suffix toggles per call so identical messages still change the text.
const text = `${msg}${counter.current % 2 ? '' : ''}`;
(level === 'assertive' ? setAssertive : setPolite)(text);
}, []);
return (
<AnnouncerContext.Provider value={announce}>
{children}
<div role="status" aria-live="polite" className="visually-hidden">{polite}</div>
<div role="alert" aria-live="assertive" className="visually-hidden">{assertive}</div>
</AnnouncerContext.Provider>
);
}
export const useAnnounce = () => useContext(AnnouncerContext);
Implementation
Step 1 — Assert the region exists before the action. This pins the timing requirement: the region is present, and empty, at first render.
// src/basket/AddToBasket.test.tsx
test('the status region is present and empty before anything happens', () => {
render(<AnnouncerProvider><AddToBasket sku="MUG" /></AnnouncerProvider>);
expect(screen.getByRole('status')).toBeEmptyDOMElement();
});
Step 2 — Assert the announcement text after the action. Normalise the invisible suffix away when comparing.
const spoken = (el: HTMLElement) => el.textContent!.replace(//g, '');
test('announces the addition politely', async () => {
const user = userEvent.setup();
render(<AnnouncerProvider><AddToBasket sku="MUG" /></AnnouncerProvider>);
await user.click(screen.getByRole('button', { name: 'Add to basket' }));
expect(spoken(screen.getByRole('status'))).toBe('Mug added to basket. 1 item in basket.');
});
Step 3 — Check a repeated action produces a new announcement. The text must change on the second action, or the second is silent.
test('a second identical action is announced again', async () => {
const user = userEvent.setup();
render(<AnnouncerProvider><AddToBasket sku="MUG" /></AnnouncerProvider>);
const button = screen.getByRole('button', { name: 'Add to basket' });
await user.click(button);
const first = screen.getByRole('status').textContent;
await user.click(button);
const second = screen.getByRole('status').textContent;
expect(second).not.toBe(first);
expect(spoken(screen.getByRole('status'))).toBe('Mug added to basket. 2 items in basket.');
});
Step 4 — Pin the politeness of each message. Routine confirmations go to the polite region; blocking errors to the assertive one.
test('a failed add is announced assertively', async () => {
server.use(http.post('/api/basket', () => new HttpResponse(null, { status: 409 })));
const user = userEvent.setup();
render(<AnnouncerProvider><AddToBasket sku="MUG" /></AnnouncerProvider>);
await user.click(screen.getByRole('button', { name: 'Add to basket' }));
expect(spoken(await screen.findByRole('alert'))).toBe('Mug is out of stock and was not added.');
expect(screen.getByRole('status')).toBeEmptyDOMElement();
});
Step 5 — Test search result counts are announced after typing settles. Announcing every keystroke’s count floods the user; announcing once after the debounce is right.
test('announces the result count once the search settles', async () => {
vi.useFakeTimers();
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<AnnouncerProvider><ProductSearch /></AnnouncerProvider>);
await user.type(screen.getByRole('searchbox', { name: 'Search products' }), 'mug');
expect(screen.getByRole('status')).toBeEmptyDOMElement();
await act(() => vi.advanceTimersByTimeAsync(400));
expect(spoken(await screen.findByRole('status'))).toBe('4 products found');
vi.useRealTimers();
});
Step 6 — Run an axe check, and a real screen reader for key flows. Automated checks confirm roles and attributes; only a screen reader confirms what is actually spoken. Reserve manual or tool-driven screen-reader checks for the flows where announcements matter most.
Step 7 — Test that messages are cleared after they have been read. A region that keeps its last message around will be re-read when a screen-reader user navigates through the page later, long after the event it described. Clearing the text after a few seconds keeps the region quiet until the next real change. With fake timers the test is deterministic: perform the action, assert the message, advance the clock past the clearing delay, and assert the region is empty again. Keep the delay generous — long enough for a slow speech rate to finish the sentence — and put the number in one shared constant so the component and its test cannot drift apart.
test('the confirmation is cleared after the display period', async () => {
vi.useFakeTimers();
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<AnnouncerProvider><AddToBasket sku="MUG" /></AnnouncerProvider>);
await user.click(screen.getByRole('button', { name: 'Add to basket' }));
expect(spoken(screen.getByRole('status'))).toContain('Mug added');
await act(() => vi.advanceTimersByTimeAsync(ANNOUNCE_CLEAR_MS));
expect(screen.getByRole('status')).toBeEmptyDOMElement();
vi.useRealTimers();
});
Verification
Confirm the timing test guards against the insertion bug by changing the component to render its message conditionally with {msg && <div role="status">}. The first test must fail, since no status region exists before the action.
npx vitest run src/basket/AddToBasket.test.tsx --reporter=verbose
# ✓ the status region is present and empty before anything happens
# ✓ announces the addition politely
# ✓ a second identical action is announced again
# ✓ a failed add is announced assertively
Then confirm the repetition test is meaningful by removing the alternating suffix from the announcer. The two captured texts become identical and the test fails — exactly the case where a screen-reader user hears only the first addition.
Troubleshooting
Symptom: getByRole('status') finds two regions. Diagnosis: a component renders its own status region alongside the shared announcer. Fix: route announcements through the announcer, or scope the query with within to the component’s own region if it genuinely needs one.
Symptom: the region contains stale text from a previous action. Diagnosis: messages are never cleared, so an old confirmation is re-read by users navigating the page. Fix: clear the region after a short delay, and assert with fake timers that it empties.
Symptom: axe reports no issues but users report silence. Diagnosis: the region is created with its content, which axe cannot detect as a timing problem. Fix: the presence-before-action test in Step 1 is the check axe cannot perform.
Symptom: the visually hidden region is hidden from assistive technology too. Diagnosis: it uses display: none or hidden, which removes it from the accessibility tree. Fix: use a visually-hidden class that clips the element rather than hiding it, and assert it is not aria-hidden.
FAQ
Should every state change be announced?
No — only changes a user would otherwise miss. Content that appears where focus already is, or after an explicit navigation, is read naturally. Announce asynchronous results, background confirmations and errors not near the user’s focus.
Can jsdom tell me what a screen reader will say?
Not reliably. It confirms the DOM that assistive technology reads — roles, text, changes — but announcement behaviour varies between screen readers and browsers. The component tests guarantee the preconditions; a real screen reader confirms the outcome.
How do I test announcements inside a modal?
The same way, with the announcer placed outside the modal so it is not trapped or hidden when the modal opens. Test that an action inside the dialog still reaches the shared region, since a modal that sets aria-hidden on the rest of the page can silence it — see testing focus management in modals.
Is aria-live on a container enough?
Only if the container is present before its contents change, and only for the contents it wraps. Prefer the explicit status and alert roles, which carry the right politeness and are what the queries in this guide rely on.
Where should the announcer live in the component tree?
At the application root, rendered once, outside any layout that can be hidden, replaced or unmounted during navigation. In tests, wrap the component under test in the same provider through a shared render helper, so every test gets the real regions rather than a stub that would hide timing bugs.
Related
- Back to Accessibility Testing for Components
- Testing ARIA roles with Testing Library — the role queries used here.
- Automating axe accessibility checks in Vitest — the attribute checks that complement these tests.
- Testing multi-step form wizards — progress announcements between steps.