How do you announce dynamic content (toasts, async results, errors) to a screen reader? polite vs assertive?
When and how AT announces DOM changes, the politeness levels, and the timing pitfalls.
Screen readers announce the focused element; content that changes somewhere ELSE on the page — a toast, 'saved', search-result counts, an async error — is silent unless it's in a LIVE REGION. A live region is a container the AT watches; when its contents change, the AT queues an announcement without moving focus. Politeness: aria-live="polite" waits until the user is idle (doesn't interrupt typing) — the default choice for status updates; aria-live="assertive" interrupts immediately — reserve for urgent, time-critical messages (a session-expiry warning, a submission error) because it's disruptive. Two convenience roles bundle this: role="status" ≈ polite (also implies aria-atomic behaviour for status), and role="alert" ≈ assertive. The single biggest pitfall: the live region MUST already exist in the DOM (empty) BEFORE you put text in it. If you inject the region and its message together, many screen readers miss the change because they only announce mutations to regions they were already observing — so render an empty <div aria-live="polite"> up front and update its textContent later. Other controls: aria-atomic="true" makes the AT read the WHOLE region on any change (vs just the changed node) — use for a message that only makes sense as a unit; aria-relevant tunes which mutation types announce. Keep messages short, don't stuff a live region with a whole page of content, avoid multiple assertive regions competing, and clear/replace text so the same message announced twice actually re-announces (some AT needs the text to change).
Toasts, async status, form errors, result counts — announcing changes without moving focus.
// The region must exist (empty) BEFORE the message is written into it.
function StatusRegion({ message }) {
return (
<div aria-live="polite" aria-atomic="true" className="sr-only">
{message} {/* update state later -> AT announces politely */}
</div>
);
}
// Urgent, interrupts: role="alert" ≈ assertive
function ErrorBanner({ error }) {
return error ? <div role="alert">{error}</div> : null;
}
// WRONG: injecting the region + text together often isn't announced
// container.innerHTML = '<div aria-live="polite">Saved</div>';