A chat surface that streams tokens is a screen-reader minefield: naive aria-live on the message container makes AT re-announce the growing message dozens of times, while no live region at all means the response arrives in silence. Streaming UI needs a designed announcement strategy, deliberate focus management, and non-visual affordances for stop and retry.
Tier: Advanced · Workflow area: Accessibility
This playbook is the accessibility layer for the chat and generative-UI surfaces built in the AI-features playbooks (pb-47–pb-51) — the part that makes them usable with assistive tech, not just impressive on a demo screen.
Skip it only if your AI feature has no streaming and no dynamic insertion — a plain request/response form follows ordinary form accessibility.
aria-live semantics: polite vs assertive, aria-atomic, and the rule that live regions must exist in the DOM before content is inserted.Choose the announcement strategy: status summaries, not token echo. Do not put aria-live on the streaming message body. Instead, keep the message body a normal DOM region and drive a separate, visually hidden status region with discrete, meaningful updates:
// Mounted once, empty, before any streaming begins
<div aria-live="polite" className="visually-hidden" id="ai-status" />
Announce state transitions, not tokens: "Assistant is responding", then either nothing until completion or sparse progress ("Still responding…") on a long stream, then "Response ready, 3 paragraphs. Press Alt+R to read." The user then navigates to the finished message and reads it with full reading commands — pause, re-read, by-word — which token-by-token announcement destroys.
Decide the reading handoff. When the stream completes, the user needs a fast path to the content. Options, in order of preference: a completion announcement plus a keyboard shortcut that moves focus to the new message; a "skip to latest response" link; or focus moved automatically only if the user initiated the request and has not interacted since. Never yank focus mid-stream — moving focus while the user is typing a follow-up is hostile.
Make stop, regenerate, and retry first-class non-visually. Each control needs an accessible name that reflects state ("Stop generating", disabled → "Regenerate response"), keyboard reachability while the stream runs, and an announced outcome:
body when content is replaced.role="alert" (or assertive live region) for the failure — errors are the one case where interrupting is correct — with the retry button focusable immediately after.Handle thinking indicators and generative UI parts. A spinner with no text is invisible to AT: give the thinking state text ("Assistant is thinking") surfaced through the status region once, not on a loop. For generative UI (pb-49), each tool part that renders — a weather card, an order status — is a content insertion: announce its arrival by name ("Order status card added"), ensure the rendered component is itself accessible (it's your design system's component, so it inherits pb-30's gates), and keep loading→partial→complete transitions from spamming announcements by announcing only terminal states.
Test the full matrix with a slowed stream. With the mock stream at readable pace, run NVDA or VoiceOver through: send → thinking → streaming → complete → read message; send → stop mid-stream; send → error → retry; regenerate over an existing message; and a long response arriving while composing the next prompt. Log every silence, every over-announcement, and every focus loss as a defect. Then have an assistant review your findings against your implementation to propose fixes — but the matrix itself is human-run.
Streaming accessibility is an announcement design problem: say that things are happening, not everything that happens. Status transitions go to a polite live region, errors interrupt, finished content is read with real reading commands, and focus never moves without the user's intent.
announceForAccessibility/UIAccessibility.post(notification:) on iOS and announce/live-region semantics in Compose — announce transitions, never tokens.body, never stolen while composing.aria-live on the message container. The classic failure: every DOM append re-triggers announcement, and long responses become an unlistenable stutter. Status region separate from content, always.aria-atomic="true" on a growing region. Forces re-reading the entire accumulated text on each update — the stutter's worse cousin.All verification is local: screen readers, mock streams, and RTL tests need no external services.
Do not ship a streaming AI surface to production having only tested it visually. Silent failure for screen-reader users is both an exclusion of exactly the users AI assistance could serve best and, for regulated sectors, a legal exposure — the "AI feature" label changes nothing about WCAG obligations.