How Should a Streaming Chat UI Announce Its Response to Screen Readers?
Generative AI chat UIs reveal answers a few characters at a time. Piping those updates into a live region makes screen readers read the response in fragments. I survey ChatGPT, Claude, and four chat UI libraries, then build an accessible sample.
Since generative AI arrived, chat UIs that let you talk to an AI through a text box have become part of everyday life. Traditional chat apps typically appended each message once it was complete, but an LLM (large language model) reveals its response gradually, over several seconds to tens of seconds.
Sighted users can tell that generation has started, and that it is still going, simply by watching the text grow. Screen readers, on the other hand, do not announce content just because the DOM changed. Without any extra work, users never learn that a response has started, and they are not told when it finishes.
The usual way to tell assistive technology that content has been added dynamically is an ARIA live region. Implement it the wrong way, though, and you risk the opposite problem: the half-finished text gets announced over and over in fragments. In the video below, the screen reader repeats the first few characters every time a new chunk arrives, stuttering endlessly.
In this article I look at how existing services and libraries announce streaming responses to screen readers, then build a sample that puts those findings into practice.
The core practice is this: do not make the streaming response body itself an automatic announcement target. Instead, announce only short status messages such as "Generating a response" or "Response complete" from a separate element. Separating the response body from the status announcement prevents the in-progress text from being read out in fragments, while still communicating when generation starts and finishes.
The screen reader behavior described in this article was verified in the following environment.
- macOS 26.5.1
- Google Chrome 151.0.7922.77
- VoiceOver bundled with macOS
What Is an ARIA Live Region?
When the content of a page changes while the user is not interacting with it — as in a chat UI — you need some way to communicate that change. ARIA live regions are the mechanism for telling screen readers about dynamic changes. When the content of an element carrying aria-live changes, the browser passes that change to assistive technology through the accessibility API. Let's start with the basics.
<!-- Content is expected to be updated dynamically with JavaScript -->
<div aria-live="polite"></div>These are the aria-live values you will mostly use.
polite: announce at a moment that does not interrupt what the user is currently listening toassertive: interrupt the current announcementoff: do not announce changes automatically, except in cases such as when the element has focus
Frequent interruptions get in the user's way, so use polite for ordinary status announcements. Reserve assertive for the small number of cases that genuinely need immediate attention, such as important warnings.
Some ARIA roles carry an implicit aria-live. For example, the status role implies aria-live="polite" and aria-atomic="true", and the log role implies aria-live="polite".
aria-atomic="true" says that even when only part of the live region changes, the region should be announced as a single unit. Say a result count changes from 1 to 2: with aria-atomic="true", the screen reader announces the whole thing — "2 results found." Without it, the user hears just "2" and has no idea what the 2 refers to.
<div role="status" aria-atomic="true">
<span id="result-count">1</span> results found
</div>
<button id="search">Search</button>
<script>
const count = document.querySelector("#result-count");
const search = document.querySelector("#search");
search.addEventListener("click", () => {
count.textContent = "2";
});
</script>role="log" Is the Role for Chat History
The log role represents a region where old information is not removed and new information is appended to the end in a meaningful order. Chat history, error logs, and game logs are typical examples.
The W3C's WCAG Techniques even use a chat conversation history as its example of role="log". In other words, applying role="log" to chat history is itself a standard approach.
<div role="log" aria-label="Chat history">
<!-- New messages are appended to the end -->
</div>In chat UIs from before LLMs, a message the user sent was typically appended to the DOM in its finished state. Just specifying role="log" was enough for a screen reader to announce a new message once. Since LLMs arrived, though, the response is generated bit by bit: an element is added for the response, and then its text is rewritten many times over. Repeating those updates inside a role="log" — which is implicitly aria-live="polite" — risks assistive technology announcing the half-finished text on every single change.
So in the post-LLM era, role="log" alone is not enough. The way streaming responses get announced needs careful design. Let's look at how real services and libraries handle it.
ChatGPT's HTML Structure
First, let's see what DOM ChatGPT builds while a conversation is being generated.
ChatGPT does not wrap the conversation in any particular role. Each turn from the user and from ChatGPT is expressed as a <section> element, with a visually hidden <h4> identifying the sender. On screen you can tell a user message from an AI response by which side the bubble sits on, but a screen reader cannot perceive that visual difference — which is presumably why the sender is spelled out in visually hidden text.
<section data-turn="user">
<h4 class="sr-only">You:</h4>
<!-- User's message -->
</section>
<section data-turn="assistant">
<h4 class="sr-only">ChatGPT:</h4>
<!-- ChatGPT's response -->
</section>sr-only is a CSS class that hides content visually while keeping it readable by screen readers. Using display: none or hidden would remove it from the accessibility tree as well, so techniques such as clipping the element down to a 1px area are used instead.
ChatGPT's response body carries neither aria-live nor aria-busy. In other words, the dynamically updated response text is never announced automatically. Separately from the response, there is a visually hidden element with role="status" aria-live="polite" aria-atomic="true". That element is updated with text such as "Thinking" while generating and "Response complete" once generation ends, announcing the state of the response.
<div role="status" aria-live="polite" aria-atomic="true" class="sr-only"></div>
<script>
const status = document.querySelector('[role="status"]');
// When response generation starts
status.textContent = "Thinking";
// When response generation completes
status.textContent = "Response complete";
</script>Checking this with a screen reader confirms it: after submitting the form you hear "Thinking," and when generation finishes you hear "Response complete."
So ChatGPT never makes the body itself an announcement target; it communicates state through short text instead. Screen reader users learn that the response is complete, and can then read through the response body whenever they choose.
Claude's HTML Structure
Claude's conversation history uses the ARIA feed pattern. A feed represents a collection of content added in a meaningful order, of the kind users scroll through to review earlier messages.
<div role="feed" aria-label="Chat messages">
<div
role="article"
aria-posinset="1"
aria-setsize="2"
aria-label="Message 1 of 2"
>
<!-- Message -->
</div>
</div>Each message is a role="article", and aria-posinset and aria-setsize express its position within the history and the total count.
aria-posinset: which position the element occupies in the set. In the example above, the first messagearia-setsize: how many elements the whole set contains. In the example above, two messages in total
You use these attributes when the position and total cannot be inferred from the number of elements present in the DOM. In a UI like chat history, where older messages are lazily loaded, only some of the messages may exist in the DOM — so these attributes let you convey something like "item 15 of 20" to assistive technology correctly.
While a response is being generated it carries aria-label="Currently streaming message", and once generation completes the name changes to something like aria-label="Message 2 of 2".
Like ChatGPT, Claude places an sr-only heading at the start of each message to identify the sender. User messages use "You said," and Claude's responses use "Claude responded."
<div role="article" aria-label="Message 1 of 2">
<h2 class="sr-only">You said: What day is it today?</h2>
<!-- User's message -->
</div>
<div role="article" aria-label="Message 2 of 2">
<h2 class="sr-only">Claude responded: August 11 is Mountain Day in Japan</h2>
<!-- Claude's response -->
</div>On screen you can distinguish senders by message position and styling, but none of that visual difference reaches a screen reader. Spelling out the sender as a heading not only tells users who said what, it also lets them jump between messages using heading navigation.
feed is not a role you simply slap on any scrollable list. Claude includes screen-reader-only instructions saying that the up and down arrow keys move between messages, and each article is focusable. If you adopt feed, you need to implement its conventions for focus movement and content loading too.
Claude also keeps the streaming response body out of any live region. A separate role="status" announces "Claude is responding" when generation starts and "Claude finished the response" when it ends. Focus stayed in the input field even after the response completed.
ChatGPT and Claude structure their history differently, but they share these three traits.
- The streaming response body is never announced as a live region
- Short status text is announced from a
role="status"separate from the response body sr-onlyheadings distinguish the user's messages from the AI's
How Chat UI Libraries Implement This
Next I looked at how front-end libraries for building chat UIs handle it. I checked these four.
- assistant-ui
- Vercel AI Elements
- CopilotKit
- shadcn/ui Chat Components
After reading their implementations, I verified the screen reader output for each one with VoiceOver and Chrome.
assistant-ui
assistant-ui's default Thread component renders the history and each message as plain <div> elements. The response body in the default template carries neither role="log" nor aria-live.
The dot indicator shown during generation has aria-label="Assistant is working", but that element is not a live region. The separately provided TypingIndicator component, on the other hand, does use role="status" with aria-label="Assistant is typing".
So screen readers are told that a response is being generated, but there is no mechanism to announce that it finished. As a result, screen reader users have no way to notice that generation is done.
Vercel AI Elements
Vercel AI Elements' Conversation component carries role="log". That means the conversation history becomes an implicitly polite live region even if you never add aria-live yourself.
Consequently, when the response body streams in, the screen reader may read the in-progress text out in fragments.
CopilotKit
For CopilotKit I checked the current v1 Messages component and v2 CopilotChatMessageView.
Both build the message list out of ordinary <div> elements. I found no role="log", aria-live, or role="status" covering the streaming response.
Any mechanism for announcing the start and completion of a response has to be added by the application.
shadcn/ui Chat Components
shadcn/ui shipped official Chat Components in June 2026.
Its central MessageScrollerContent sets role="log" and aria-relevant="additions" by default. aria-relevant specifies which kinds of changes inside a live region should be announced. These are the values you can use.
additions: changes where an element node is added inside the live regiontext: changes where text content or a text node is addedremovals: changes where text or an element node is removedall: equivalent toadditions removals text
If you do not specify aria-relevant, the default is additions text. That is, both element additions and text changes are announced.
shadcn/ui deliberately specifies only additions in order to drop text from that default. As a result, adding a new message element is announced, while rewriting the contents of an existing element via streaming falls outside the scope. It is precisely a way to avoid streaming responses being read out in fragments.
That said, assistive technologies interpret aria-relevant differently. According to the test results on Accessibility Support, the default additions text is supported across all screen readers, whereas specifying additions on its own is unsupported in NVDA and only partially supported in VoiceOver and JAWS.
The streaming example also sets aria-busy="true" on MessageScrollerContent while a response is in flight.
aria-busy indicates that updates to an element are not yet complete. Per the specification, assistive technology may hold changes while aria-busy="true" and process them together once it becomes false. But the WAI-ARIA specification frames holding those announcements as a "MAY" requirement, so different screen reader and browser combinations will not necessarily produce the same output.
Testing with VoiceOver and Chrome, I confirmed that announcements were held while the response was generating and only read out, all at once, after generation completed. That does avoid the fragmented-announcement problem, but a long response being read out in one go after completion can get in the user's way. Excessive announcements are not a good experience. See also Understanding Success Criterion 4.1.3: Status Messages.
Summary of the Library Survey
Here is what the four libraries do, side by side.
| Library | History role | Announced during generation | Announced on completion | What you need to add |
|---|---|---|---|---|
| assistant-ui | None (<div>) |
role="status" (TypingIndicator) |
None | A mechanism to announce completion |
| Vercel AI Elements | role="log" |
Response body announced implicitly | None | aria-live="off" on the history, plus a separate status announcement |
| CopilotKit | None (<div>) |
None | None | Announcements for both start and completion |
| shadcn/ui Chat Components | role="log" + aria-relevant="additions" |
Held via aria-busy="true" |
Body read out in full after completion | Replacing this with status announcements, plus per-AT testing |
As you can see, there is not much consistency between libraries. What they do have in common is that whichever one you pick, the mechanism for announcing the start and completion of a response as short text is something your application has to provide. Testing with an actual screen reader matters too, since behavior can differ between assistive technologies.
A Real Issue Where Every Token Was Announced
There is a real-world case where updating a streaming response inside a live region caused problems. OpenClaw streamed responses inside a chat history carrying role="log" and aria-live="polite". The result was an issue reporting that responses were announced token by token, or chunk by chunk, in the NVDA and Firefox combination. Silencing the speech did not help, because the next update started it again, leaving the UI nearly unusable while a response was in flight.
The issue proposed switching to aria-live="off" only during generation, and holding updates with aria-busy. But because testing with real assistive technology was lacking and cross-screen-reader compatibility was unclear, the proposed PRs were not merged as-is.
OpenClaw today uses role="log" aria-live="off" for the history and announces the completed response from a separate, visually hidden role="status" aria-live="polite" aria-atomic="true". With this setup the response body itself is never announced to screen readers; only short text such as "Response complete" is.
This case reinforces how important it is to design the streaming body and the automatically announced content as two separate things.
Building an Accessible Chat UI
With all of that in hand, let's pin down what a chat UI needs in accessibility terms. Three things matter most.
- The response body is not announced to screen readers automatically
- The start, completion, cancellation, and failure of a response are announced to screen readers
- The sender of each message is discoverable by screen readers, without relying on visual presentation
Let's build a sample chat UI that satisfies these. First, give the chat history container role="log" along with aria-live="off".
<div
id="messages"
role="log"
aria-live="off"
aria-label="Chat history"
tabindex="0"
>
<!-- Streaming messages -->
</div>Specifying aria-live="off" disables the implicit live region that comes with role="log", so the streaming response body is no longer read out automatically.
You might wonder whether there is any point in specifying role="log" at all, then. But even with aria-live disabled, the role and the accessible name remain in the accessibility tree. Inspecting Chrome's accessibility tree confirms it: even with aria-live="off", the element is still exposed as a log named "Chat history."
In other words, silencing automatic announcements and describing what the region is are two separate concerns. role="log" stays to tell assistive technology that this region is "a history where old information is not removed and new information is appended in order."
tabindex="0" is there because the history region is scrollable. If the user cannot put focus on a scrollable region, keyboard users who do not use a mouse cannot scroll back to older messages. Since aria-label is set, focusing it announces "Chat history."
That alone, though, still leaves screen reader users unaware of when a response starts or finishes.
So alongside the history, prepare an empty role="status". Updating its text when a response starts, completes, is cancelled, or fails is what announces those events to screen readers. Because the contents of a role="status" element inserted into the DOM dynamically may not be announced, you need to place it in the DOM from the start.
<div
id="chat-status"
class="sr-only"
role="status"
aria-live="polite"
aria-atomic="true"
></div>role="status" implies aria-live="polite" and aria-atomic="true", so omitting those attributes would mean the same thing — they are spelled out here for clarity.
This content should never appear on screen; only screen readers should read it. As mentioned earlier, display: none and hidden would strip it from the accessibility tree too, so we define an .sr-only class to hide it visually instead.
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}Then update only the status text as the response moves through its lifecycle.
function announce(message) {
const status = document.querySelector("#chat-status");
status.textContent = "";
setTimeout(() => {
status.textContent = message;
}, 100);
}
announce("Generating a response");
// Only the response body is updated while streaming
announce("Response complete");Note that textContent is cleared first and then set after a short setTimeout. Screen readers announce live regions by detecting changes, so assigning exactly the same text as before may not count as a change and may go unannounced. Clearing it to an empty string first is an implementation trick that makes a repeated message more likely to be detected as a change. Bear in mind that the actual result depends on the browser and assistive technology combination.
Cancellation and failure are announced politely from the same status.
announce("Response generation cancelled");
announce("Could not generate a response. Please try again");A generation failure is not usually an emergency that must interrupt whatever the user is doing, so I do not use role="alert" or aria-live="assertive".
Don't Convey the Sender Visually Only
Chat UIs typically indicate the sender by putting user messages on the right and AI responses on the left, or by changing the bubble color. But position and color alone tell a screen reader nothing about who sent what.
In the sample, each message is an <article> with an sr-only heading identifying the sender.
<article aria-labelledby="message-2-author">
<h2 id="message-2-author" class="sr-only">AI response</h2>
<div>Response body</div>
</article>This way, users reading back through the history can still tell who said what.
Keep Focus in the Input After Submitting
Never force focus onto the response when generation starts or completes. The user may lose what they were typing, or their place in what they were reading. The specification for the status role likewise asks that focus not be moved to a status merely because its state changed.
In the sample, focus stays in the input field after submitting. The "Stop" button shown during generation is a regular <button>, so users who need it can reach it with the Tab key. When the "Stop" button is pressed, focus returns to the input field the moment the button disappears, so the user can go straight on to the next question.
Verifying with VoiceOver and Chrome
Checking the sample with VoiceOver and Chrome confirms the following behavior.
- After the form is submitted, "Generating a response" is announced once
- The response body streaming in is not read out automatically
- On completion, "Response complete" is announced once
- Focus stays in the input field both after submitting and after completion
- Reading back through the history reveals "Your message" and "AI response" headings
You can see the full code in the CodePen below.
Summary
role="log"is the standard role for chat history, and it impliesaria-live="polite"- Streaming an LLM response directly inside a
role="log"can announce the half-finished text in fragments - ChatGPT and Claude announce generation state from a
role="status"separate from the streaming response body - Chat UI libraries are not consistent about this; whichever one you pick, your application has to provide the mechanism that announces the start and completion of a response as short text
- The sample gives the history
role="log" aria-live="off"and announces start, completion, cancellation, and failure briefly from a separaterole="status" - Do not rely on
aria-busyalone to control announcements; test with the screen readers and browsers you target
References
- WAI-ARIA 1.2: log role
- WAI-ARIA 1.2: status role
- WAI-ARIA 1.2: aria-busy
- ARIA23: Using role=log to identify sequential information updates
- Understanding Success Criterion 4.1.3: Status Messages
- Feed Pattern | APG | WAI | W3C
- Test: aria-relevant attribute | Accessibility Support
- OpenClaw: Screen readers announce every token during streaming
- assistant-ui
- Vercel AI Elements
- CopilotKit
- shadcn/ui Chat Components



