Search IOCombats

Search challenges, guides, questions and articles

How do you make streaming AI responses accessible to screen reader users?

Advanced12 min interview
Skills tested:
Explaining how live regions and polite announcements workKnowing why token-by-token updates are a problem for screen readersDesigning a separate status region for lifecycle announcementsKnowing the limits of aria-busy and support differencesPlanning focus management and message structure in a chat UI

Advertisement

🧩 Scenario

An accessibility audit of your AI chat finds that a screen reader either reads the answer in broken fragments while it streams or says nothing at all, depending on the browser. There is also no way to know when the answer has finished. The interviewer asks how live regions work and how you would redesign the experience.

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 implicitly polite and role="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

  1. Structure, not live speech, for the message list. Keep role="log" with an accessible name like "Chat history" if you want it, but set aria-live="off" on it so streamed text does not trigger announcements.
  2. 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".
  3. Hide it visually, not from assistive technology. Use an sr-only clipping class. display: none or hidden removes it from the accessibility tree.
  4. 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.
  5. 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

ApproachWhat the user hearsRisk
Streaming text inside a polite live regionFragments as tokens arriveNoisy, inconsistent across screen readers
aria-busy on the streaming messagePossibly the whole answer at the endSupport varies, long answers read in one burst
Separate status region with lifecycle messagesShort, predictable updatesUser must navigate to read the answer
Assertive announcementsImmediate interruptionDisruptive, 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

  1. How would you announce progress for a long-running tool call that has several steps?
  2. How would you make streamed code blocks and their copy buttons accessible?
  3. What changes for a voice-first or mobile screen reader user?
  4. How would you add automated accessibility checks to CI, and what can they not catch?
  5. 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-busy does 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.

Frequently Asked Questions

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.

Namaste Dev
Sponsored

Cohort-style courses to go from tutorials to job-ready.

Explore Courses

Advertisement