How do you make streaming AI responses accessible to screen reader users?
Advertisement
🧩 Scenario
Concept Walkthrough
How live regions work
Screen readers read what the user navigates to. Content that changes on its own, like a chat answer, is silent unless you mark it as a live region. A live region tells assistive technology to announce changes inside it.
aria-live="polite"waits until the user is idle before announcing.aria-live="assertive"interrupts. Use it only for urgent things.- Some roles have a built-in live value. MDN documents that
role="log"is implicitlypoliteandrole="status"is a polite status region too.
Why streaming breaks the usual pattern
The obvious approach is to put the chat inside role="log" and let new messages announce themselves. That works for finished messages. A streamed answer, though, is not one change: it is hundreds of small DOM updates. Depending on the screen reader and browser, that can mean fragmented speech, repeated phrases or silence. A detailed write-up on accessible streaming chat UIs by azukiazusa (azukiazusa.dev) tested this and recommends keeping the streaming body out of automatic announcements.
The pattern that works
- Structure, not live speech, for the message list. Keep
role="log"with an accessible name like "Chat history" if you want it, but setaria-live="off"on it so streamed text does not trigger announcements. - A separate status region. A visually hidden element with
role="status"that is in the DOM from the first render. Announce short lifecycle messages into it: "Generating response", "Response complete", "Response stopped", "Response failed". - Hide it visually, not from assistive technology. Use an
sr-onlyclipping class.display: noneorhiddenremoves it from the accessibility tree. - Let users read the answer on their own terms. Give each message a visually hidden heading such as "Assistant said" so screen reader users can jump between messages.
- Keep focus where it is. After sending, focus stays in the input. Do not move focus when the answer completes.
What about aria-busy?
aria-busy="true" marks a region as being updated, and assistive technology may hold announcements until it is false. The spec says "may", so support varies. Where it works, the full answer might be read at the end in one long burst. Treat it as a progressive enhancement, not the main strategy.
Controls
Stop and Regenerate buttons need clear accessible names ("Stop generating", "Regenerate response"). When Stop appears or disappears, do not steal focus to it.
Key Code Explained
import { useCallback, useEffect, useRef, useState } from 'react';
// Visually hidden but still in the accessibility tree
const srOnly: React.CSSProperties = {
position: 'absolute',
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
border: 0,
};
export function useAnnouncer() {
const [message, setMessage] = useState('');
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const announce = useCallback((text: string) => {
// Clear first, then set, so repeating the same text is still a change
setMessage('');
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setMessage(text), 100);
}, []);
useEffect(() => () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
}, []);
// Render this once, from the first render, never conditionally
const region = (
<div role='status' aria-atomic='true' style={srOnly}>
{message}
</div>
);
return { announce, region };
}
// Usage inside the chat
function Chat({ status, messages }: { status: 'idle' | 'streaming' | 'done' | 'stopped' | 'error'; messages: { id: string; role: string; text: string }[] }) {
const { announce, region } = useAnnouncer();
useEffect(() => {
if (status === 'streaming') announce('Generating response');
if (status === 'done') announce('Response complete');
if (status === 'stopped') announce('Response stopped');
if (status === 'error') announce('Response failed. You can try again.');
}, [status, announce]);
return (
<>
{/* Structure for navigation, but no automatic announcements */}
<div role='log' aria-label='Chat history' aria-live='off'>
{messages.map((m) => (
<article key={m.id}>
<h3 style={srOnly}>{m.role === 'user' ? 'You said' : 'Assistant said'}</h3>
<p>{m.text}</p>
</article>
))}
</div>
{region}
</>
);
}
The status region exists before any announcement, which matters because many screen readers only track live regions that were already in the DOM. The clear-then-set step makes it more likely that announcing "Response complete" twice in a row is detected as a change, but the write-up that describes it notes the result depends on the screen reader and browser.
Tradeoffs
| Approach | What the user hears | Risk |
|---|---|---|
| Streaming text inside a polite live region | Fragments as tokens arrive | Noisy, inconsistent across screen readers |
| aria-busy on the streaming message | Possibly the whole answer at the end | Support varies, long answers read in one burst |
| Separate status region with lifecycle messages | Short, predictable updates | User must navigate to read the answer |
| Assertive announcements | Immediate interruption | Disruptive, reserve for real urgency |
What Interviewers Actually Check
- Whether you know what a live region is and polite vs assertive
- Whether you see why token-level updates are a problem
- Whether you separate "announce status" from "read content"
- Whether you know the limits of
aria-busy - Whether you plan to test with real screen readers instead of trusting the spec
Follow-Up Questions
- How would you announce progress for a long-running tool call that has several steps?
- How would you make streamed code blocks and their copy buttons accessible?
- What changes for a voice-first or mobile screen reader user?
- How would you add automated accessibility checks to CI, and what can they not catch?
- How should a "typing" animation behave for users with
prefers-reduced-motion?
Common Candidate Mistakes
- Saying "just add aria-live" without thinking about streaming
- Using
role="alert"for normal completion messages - Mounting the live region only when there is something to say
- Testing only with VoiceOver on one browser
- Forgetting accessible names for Stop and Regenerate buttons
Interview Readiness Checklist
Before you leave this question, make sure you can answer:
- Can you explain polite vs assertive and the implicit live value of
role="log"? - Can you describe why streaming text should not be announced token by token?
- Can you build a visually hidden status region that is present from the first render?
- Can you explain what
aria-busydoes and why it is not enough alone? - Can you describe focus behaviour for send, stop and completion?
Summary
Screen readers only announce changing content inside live regions, and streaming an answer into one produces hundreds of tiny updates that different screen readers handle in different ways. Keep the message list as navigable structure with announcements off, and use a separate, visually hidden role="status" region, present from the first render, to announce short lifecycle messages like start, complete, stopped and failed. Do not rely on aria-busy alone, keep focus in the input, give each message a heading, and test with more than one screen reader and browser.
Why not wrap the chat in role="log" and let it announce everything?
MDN notes role="log" has an implicit aria-live value of polite. If streamed text is written into it, each small DOM change can trigger an announcement, which can produce fragmented or repeated speech. Many teams keep role="log" for structure and set aria-live="off" on it, then announce status separately.
Does aria-busy solve the problem?
It can help, but do not rely on it alone. The WAI-ARIA spec lets assistive technology choose whether to wait for aria-busy to clear, so behaviour differs across screen readers. Where it does work, the whole answer may be read in one go at the end, which can be long and disruptive.
Should I move focus to the answer when it finishes?
Usually no. Moving focus unexpectedly disorients users, especially if they were typing a follow-up. Announce completion and let them navigate to the answer, for example with a heading per message.
Advertisement
Stay Updated
Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.
Advertisement