AI UX and Accessibility: Perceived Waiting, aria-live, Keyboard, Feedback and Transparency
A streamed AI answer should reach screen readers as one LiveAnnouncer announcement when the turn ends, not in pieces; focus should stay in the input, and the waiting time and an AI label should be visible. In this post we measure aria-live producing 70 changes for three sentences, the Send/Stop swap dropping focus to body unnoticed by axe-core, fix a contrast issue and wire thumbs up/down feedback to the backend.
Version note: The code in this post was built and run with Angular 22.2 (TypeScript 6.0, Node.js 24), Angular CDK 22.2.7 and .NET 10; the backend uses Microsoft.Extensions.AI 11.0.0 and OllamaSharp 5.5.2, browser runs used Playwright 1.65 with headless Chromium 158 and @axe-core/playwright 4.14, and the local model is Ollama 0.34.4 (qwen3:4b). Nothing was tested with a real screen reader (NVDA, VoiceOver); announcements were measured by counting DOM changes in live regions.
Guess First
The screen of the Bookstore assistant (Bookstore, the online bookstore used as the shared example across this site's series) works: streaming text, cards, approvals, errors and cancellation. Now think of a user who cannot see the screen. If you add aria-live="polite" to the area that holds the messages, how many changes reach the screen reader during a three-sentence answer? How many without it? And one more: after the user clicks the "Send" button, where does keyboard focus end up, and does an automated accessibility scan (axe-core) catch the problem?
In this post we add the accessibility and trust layer to the assistant: perceived waiting, a single announcement to the screen reader at the right time, focus and keyboard, motion and contrast, an "AI generated" label and thumbs up/down feedback. The general rules of accessibility are in the Accessibility (a11y) series; here we only look at what is specific to an AI interface.
Perceived Waiting: Status, Indicator and Time
In post 1 we measured that almost all of the time a user waits passes before the first token. In this post's runs qwen3:4b gave its first text at 38.6 seconds for "describe Dune to a teenager in three sentences", and the whole answer then streamed in 1.7 seconds. If the screen only says "Thinking…" for 38 seconds, the user assumes the page froze. I added three things: a moving indicator, a counter that appears after 3 seconds and the total time once the answer is done.
@if (message.role === 'assistant' && message.text === '' && store.busy() && last) {
<p class="status typing">
<span aria-hidden="true"></span><span aria-hidden="true"></span><span aria-hidden="true"></span>
{{ store.status() === 'submitted' ? 'Sending…' : 'Thinking…' }}
@if (elapsed() >= 3) {
<span class="elapsed">{{ elapsed() }} s</span>
}
</p>
}
// Show the waiting time second by second; not during the first seconds
let timer = 0;
effect(() => {
clearInterval(timer);
this.elapsed.set(0);
if (this.store.busy()) {
timer = setInterval(() => this.elapsed.update((s) => s + 1), 1000);
}
});
inject(DestroyRef).onDestroy(() => clearInterval(timer));
Hiding the counter for the first 3 seconds is deliberate: on short answers a number that appears and disappears is distracting, while on a long wait it says "still working". The dots are aria-hidden="true": they mean nothing to a screen reader. The tool status lines from post 5 ("Looking through the catalog…") are part of perceived waiting too; a sentence that says what the model is doing builds more trust than a spinner.
I also added the total time to the store's usage signal. When the done event arrives, the time since sending is computed:
case 'done':
ended = true;
// The total time the user waited: from sending to the last event
this.usage.update((u) => u && { ...u, durationMs: Math.round(performance.now() - startedAt) });
this.finish(id, 'complete', 'done');
break;
The line at the bottom of the screen now shows Last answer: 608 input / 159 output tokens in 3.9 s. Showing token counts to end users is not right for every product; in Bookstore this line is meant for admins and the test environment. If you need to show cost to users, take it from the provider's pricing page, dated and computed on the server; do not keep token prices in the browser.
Screen Readers and Streaming Text
First I measured the current state. In Playwright a MutationObserver records, with a timestamp, every DOM change inside elements whose aria-live is polite or assertive or whose role is log, status or alert. Each record is a change the screen reader could announce.
With the code from the end of post 6 the result was zero: the page has no live region at all. A blind user hears nothing after pressing "Send" and only notices the answer by walking through the page again. The first fix that comes to mind is adding aria-live="polite" to the message list:
<!-- DON'T: every streamed chunk becomes a separate announcement candidate -->
<section class="log" #log (scroll)="onScroll()" aria-live="polite">
$ node run07.mjs live "Describe Dune in three sentences for a teenager."
live region updates: 70
102 ms polite "Describe Dune in three sentences for a teenager.Sending…"
205 ms polite "Describe Dune in three sentences for a teenager.Thinking…"
38656 ms polite "Describe Dune in three sentences for a teenager.D"
38684 ms polite "Describe Dune in three sentences for a teenager.Dune"
# ...
40367 ms polite " thrilling, thought-provoking adventures.\n Sources: [1] Dune"
70 changes for three sentences. In post 2 we batched deltas per frame with requestAnimationFrame; without that the number would match the delta count. How screen readers merge these changes depends on the product, but either way the user hears words in pieces or keeps getting interrupted while doing something else (like reading a button). assertive is worse: every change interrupts whatever is being read. MDN also recommends assertive only for critical notifications that need immediate attention.
There is one more trap: role="log", often recommended for chat, is implicitly a live region; according to MDN's role page its implicit aria-live value is polite. So adding just role="log" to the message list produces the same 70 announcements. My choice is not to make the message list a live region at all, and to make a single announcement when the turn ends. Angular CDK's LiveAnnouncer exists for this:
// The streaming text is not in a live region; the screen reader gets a single announcement when the turn ends
let previous: ChatStatus = this.store.status();
effect(() => {
const status = this.store.status();
if (status === previous) {
return;
}
previous = status;
const last = this.store.messages().at(-1);
if (status === 'done' && last?.role === 'assistant') {
const approval = last.approval?.state === 'pending' ? last.approval : null;
const text = approval ? `Approval needed. ${describe(approval.tool, approval.arguments)}` : plainText(last.text);
void this.announcer.announce(`Assistant: ${text}`, 'polite');
} else if (status === 'aborted') {
void this.announcer.announce('Stopped.', 'polite');
}
});
// Keep markdown symbols and [n] citations out of what the screen reader reads
function plainText(markdown: string): string {
return markdown
.replace(/\[(\d+)\]/g, '')
.replace(/[*_`#>]/g, '')
.replace(/\s+/g, ' ')
.trim();
}
The announcement is tied to the ChatStore state machine (post 3): on the streaming → done transition the plain text of the answer is read, on aborted "Stopped.", and for a turn waiting for approval the question from the approval card. There is no separate announcement for errors; I added role="alert" to the error banner and the browser announces it itself. Doing both would read the same error twice. The same measurement:
$ node run07.mjs live
live region updates: 1
6351 ms polite "Assistant: Dune is longer than The Hobbit."
# the element LiveAnnouncer adds to the page
<div class="cdk-live-announcer-element cdk-visually-hidden" aria-atomic="true" aria-live="polite"
id="cdk-live-announcer-0">Assistant: Dune is longer than The Hobbit.</div>
The LiveAnnouncer element sits in the page in advance and is visually hidden; it applies MDN's rule that a live region must exist in the DOM before its content changes, so we do not have to. While streaming I also set aria-busy="true" on that message's article; it tells assistive technology that this part is still changing.
This approach has a cost: on a very long answer the user waits for the whole answer to hear the first sentence. The middle ground is to announce completed blocks (the markdown blocks we froze in post 4) one by one; I leave it to A Small Experiment.
Focus and Keyboard
I measured focus with the code from the end of post 6:
$ node run07.mjs focus
after click Send : button Send
after answer : body BookstoreBooksCart:
typed "x" goes to: nowhere
Clicking "Send" with the mouse moves focus to the button. When the stream starts, @if (store.busy()) removes the button from the DOM and puts "Stop" in its place, and focus on the removed element falls to body. When the answer finishes the same happens to "Stop". A keyboard user has to Tab from the top of the page again to type the second question; the letter they type goes nowhere. On the same page an axe-core scan said this:
$ node run07.mjs axe
empty: 0 violations
answered: 0 violations
An automated scan looks at the DOM as it is at that moment; it does not see where focus goes or what gets announced when. Only a test that runs the scenario, or trying it by hand with a keyboard, catches these two problems. The fix is two lines:
// After sending, focus stays in the input; the user can type the next question right away
this.input().nativeElement.focus();
// Do not let focus fall to body when the Send/Stop buttons swap
afterRenderEffect(() => {
this.store.busy();
if (document.activeElement === document.body) {
this.input().nativeElement.focus();
}
});
The second one only kicks in when focus was lost; if the user deliberately moved somewhere else (a source link, the approval card), it does not pull focus back. As a keyboard shortcut, Escape stops the stream; the "Stop" button advertises this with aria-keyshortcuts="Escape":
@Component({
selector: 'app-assistant',
imports: [MessageBody, GenerativeBlock, ApprovalCard],
templateUrl: './assistant.html',
styleUrl: './assistant.css',
host: { '(keydown.escape)': 'store.stop()' },
})
$ node run07.mjs focus
after click Send : input Message to the Bookstore assistant
after answer : input Message to the Bookstore assistant
typed "x" goes to: input
$ node run07.mjs escape
bubble: Stopped.
2721 ms polite "Stopped."
focus: Message to the Bookstore assistant
The input's accessible name now comes from aria-label instead of the placeholder; the placeholder disappears once the user starts typing, and giving the name explicitly is sturdier than relying on it as the only source.
Motion and Contrast
The blinking dots should stop for users who chose reduced motion in their operating system:
/* The dots stay still for users who prefer reduced motion */
@media (prefers-reduced-motion: reduce) {
.typing span[aria-hidden] {
animation: none;
}
}
In a page opened in Playwright with reducedMotion: 'reduce' the dot's animation-name is none; with the default it is the Angular-scoped _ngcontent-ng-c4214144041_blink. The second axe scan in this post did find a problem. After adding the "AI-generated" label:
answered: 1 violations
color-contrast (serious): Elements must meet minimum color contrast ratio thresholds -> 1 node(s): .meta > span
Element has insufficient color contrast of 4.39 (foreground color: #6b7280, background color: #f3f4f6,
font size: 9.0pt (12px), font weight: normal). Expected contrast ratio of 4.5:1
The same gray had been used for "Thinking…" and "Stopped." since earlier posts; it never showed up because the scan did not run while those lines were on screen. I changed the color to #4b5563 and the scan went back to zero. Automated scans are useful, but they only see the moment they scan; repeat them at different points of the flow (waiting, error, approval).
Trust and Transparency
Every completed answer now has a short line under it:
@else if (message.ending === 'complete') {
<div class="meta">
<span>AI-generated, may contain mistakes.</span>
<button type="button" class="link" [attr.aria-pressed]="message.feedback === 'up'" (click)="store.rate(message.id, 'up')">Helpful</button>
<button type="button" class="link" [attr.aria-pressed]="message.feedback === 'down'" (click)="store.rate(message.id, 'down')">Not helpful</button>
</div>
}
The label says the answer was produced by a model and may be wrong; in post 1 the same question was answered with two different books. The source list from post 4 complements this label: the user sees where to verify the answer. I also added a warning under the input and linked it to the input with aria-describedby:
<input #input [value]="draft()" (input)="draft.set(input.value)" placeholder="Ask the Bookstore assistant"
aria-label="Message to the Bookstore assistant" aria-describedby="privacy-hint" />
<p id="privacy-hint" class="hint">Do not share passwords, card numbers or other personal data with the assistant.</p>
Everything the user types goes to the server-side conversation history and from there to the model; with a cloud provider that means the data leaves for a third party. The warning is the simplest way to tell the user; stripping personal data on the server is a separate job.
Feedback: Thumbs Up and Down
The buttons report their state with aria-pressed, and a click calls rate in the store:
// Thumbs up/down: identifies the answer by its position in the server-side conversation and sends it to the backend
async rate(messageId: number, rating: 'up' | 'down'): Promise<void> {
const conversationId = this.conversationId();
const answers = this.messages().filter((m) => m.role === 'assistant' && m.ending === 'complete');
const turn = answers.findIndex((m) => m.id === messageId);
if (conversationId === null || turn === -1) {
return;
}
this.patch(messageId, { feedback: rating });
const ok = await this.api.feedback({ conversationId, turn, rating });
if (!ok) {
this.patch(messageId, { feedback: null });
}
}
// Feedback on an answer; written to the log for the evaluation loop
app.MapPost("/chat/feedback", IResult (FeedbackRequest feedback, ConversationStore conversations, ILogger<Program> logger) =>
{
if (feedback.Rating is not ("up" or "down") || conversations.Find(feedback.ConversationId) is null
|| feedback.Comment?.Length > 500)
{
return TypedResults.Problem(title: "Invalid feedback", statusCode: 400);
}
logger.LogInformation("Feedback {Rating} for conversation {ConversationId}, turn {Turn}: {Comment}",
feedback.Rating, feedback.ConversationId, feedback.Turn, feedback.Comment ?? "");
return TypedResults.NoContent();
}).RequireRateLimiting("chat");
$ node run07.mjs feedback
meta: AI-generated, may contain mistakes. | Helpful | Not helpful
aria-pressed: [ 'Helpful=false', 'Not helpful=true' ]
usage: Last answer: 608 input / 159 output tokens in 3.9 s
# server log
info: Program[0]
Feedback down for conversation c2e9164c-e7a3-4c33-8b8b-1e85cd62aee0, turn 0:
I identify the answer by turn, its position among the completed answers stored on the server. That is consistent because the server only writes turns that finished cleanly into the history (post 3), but it is fragile: when the history is trimmed or a turn is regenerated, the position can shift. In production the server should send an ID for every answer in the done event, and feedback should be tied to that ID. These log lines say nothing on their own; you need to know which prompt version, which tool calls and which model produced the answer that was disliked. The AI Agents and MCP series post "Evaluating Agents: Trajectory, Tool Call Accuracy, Task Adherence and Production Monitoring" covers the evaluation loop.
Production Notes
Try it with a real screen reader. The measurement in this post counts DOM changes; NVDA, JAWS and VoiceOver merge them differently and read them at different times. At least one Windows + NVDA run and one mobile + VoiceOver run belong on your pre-release list.
Mind the limit of reading a long answer in one go. An answer of several paragraphs becomes a long monologue in a single announcement; a screen reader user must still be able to walk through the answer in the message list. That is why a navigable message list (each message an article) matters.
Store feedback with its context. Without the answer ID, the prompt version, the model name and the tool calls, a count of "not helpful" does not tell you what to fix. Treat the user's free-text comment as personal data too.
Keep status texts short. "Thinking…", "Looking through the catalog…" and the timer improve perceived waiting, but all of them on screen at once get crowded. That is why the waiting line disappears once the stream starts.
Common Pitfalls
First, putting streaming text in a live region. With aria-live="polite" three sentences produced 70 changes; with assertive each one interrupts what is being read.
Second, forgetting that role="log" is implicitly a live region. The role that looks right for a chat gives the same result without writing aria-live.
Third, buttons swapped by @if losing focus. The Send/Stop swap dropped focus to body, and axe-core did not see it.
Fourth, announcing the same thing twice. With the error banner as role="alert", also reading the error through LiveAnnouncer makes the user hear the same sentence twice.
Fifth, collecting feedback without matching it to the answer. Matching by position is enough for a demo; production needs an answer ID issued by the server.
A Small Experiment
★☆☆ When "Not helpful" is pressed, open a short comment field (at most 500 characters) and send the comment as comment. When the field opens, focus should move to it; after sending, return to the input with a "Thanks for the feedback." announcement.
★★☆ Add an answerId to the server's done event, carry it into ChatMessage and send feedback with this ID instead of turn. Check whether feedback on a regenerated answer gets mixed up with the old one.
★★★ Announce long answers block by block: when each block frozen by StreamingMarkdown from post 4 completes, announce only that block's plain text with LiveAnnouncer. Repeat the run07.mjs live measurement: how many announcements does a three-paragraph answer produce?
The two important problems in this post (lost focus and a missing announcement) were not caught by an automated scan but by a scenario test. In the next post we turn such scenarios into permanent tests: fake streams that take the model out of the loop, Vitest fake timers for the 30-second timeout, an event stream at the network level with MSW, and verifying in Playwright with page.route that cancellation really cuts the request.