I was halfway through a streamed answer when NVDA suddenly said heading level two, Implementation. My place in the sentence vanished, and the heading rotor listed three Implementation entries that had not existed a second earlier. Has your chat UI ever stolen the screen-reader cursor while the model was still talking? Mine did, and the send button was not even the villain this time.
I had been checking whether a long answer stayed keyboard-operable through loading, cancel, and recovery. The composer still had a visible Stop control, which looked fine in the screenshot. The failure lived in the accessibility tree, where each ## in the stream became a real heading the moment a newline arrived.
The interaction that actually broke
The visible UI looked almost healthy. Tokens were painting, Stop stayed in the tab order, and a polite live region was “doing accessibility.” Then the model emitted a Markdown heading, the renderer promoted it immediately, and NVDA jumped as if I had clicked a table of contents I never asked for.
Here is the transition I keep reproducing, written down before I touch CSS:
- Focus sits on Stop or on the growing transcript, depending on how you tab.
- The stream prints
## Implementationplus a newline. - The Markdown renderer commits an
<h2>insidearia-live="polite". - The screen reader announces the heading and abandons the sentence it was reading.
- A later token rewrites the same title, so the rotor now contains duplicate, disappearing headings.
Does a toast help here? It does not. The user did not miss a status. They lost a reading position inside a document that was still being invented.
Expected UI states
I put the state table on the whiteboard before rewriting the component, because happy-path streaming demos hide this class of bug.
| State | Composer | Transcript | Announcements | Focus |
|---|---|---|---|---|
idle |
Send enabled | Static article, real headings allowed | Silent | Composer |
streamingPlain |
Stop enabled, Send disabled | Plain text only, no h1–h6
|
Throttled “Still answering” | Unchanged |
settlingRich |
Both disabled briefly | Markdown parsed once | “Answer ready, N headings” | Stay put |
ready |
Send enabled | Settled article | Silent | Composer unless user is reading |
cancelled |
Send enabled | Partial plain text plus cancelled label | “Answer cancelled” | Composer |
error |
Send enabled | Error in role="alert"
|
Error text once | Alert, then composer |
If your table only has loading and done, the heading injection path has nowhere to live. That is how it ships.
How I isolated it without guessing
I stopped arguing about ARIA and started logging the tree. Analogies help, but the accessibility tree is the crime scene.
Think of a live region as a news ticker, not as a book. A ticker can shout “still answering.” A book can contain headings, lists, and skip targets. When you feed a book through a ticker, every chapter title becomes a breaking-news interruption. Why would we expect NVDA to stay calm?
Reusable debugging steps that actually moved the needle:
- Open Chrome DevTools → Accessibility on the transcript node and watch it while tokens arrive.
- Enable NVDA Speech Viewer so you can see announcements you would otherwise talk over.
- In the page console, attach a
MutationObserverto every[aria-live]node and log addedh1–h6elements. - Replay the same prompt with a slow stream, not a mocked
setTimeoutof three words. - After cancel, inspect whether those headings remained in the rotor.
The observer that made the bug undeniable looks like this:
// Proposed reproduction helper — paste into DevTools on the chat page.
const lives = document.querySelectorAll("[aria-live]");
const obs = new MutationObserver((records) => {
for (const record of records) {
for (const node of record.addedNodes) {
if (!(node instanceof Element)) continue;
const heading = node.matches("h1,h2,h3,h4,h5,h6")
? node
: node.querySelector("h1,h2,h3,h4,h5,h6");
if (heading) {
console.warn("heading entered a live region", {
live: record.target.getAttribute("aria-live"),
tag: heading.tagName,
text: heading.textContent,
});
}
}
}
});
lives.forEach((el) => obs.observe(el, { childList: true, subtree: true }));
The first run printed three warnings in four seconds. That is not a screen-reader quirk. That is your renderer committing document structure into a region whose job is to interrupt.
Root cause: promoting Markdown before the turn is a document
The client was doing something that looks responsible in code review. It streamed tokens into a buffer, parsed GitHub-flavored Markdown on every animation frame, and set aria-live="polite" on the same article so “users hear the answer.”
// Buggy sketch — do not ship this pattern.
function StreamingAnswer({ buffer }: { buffer: string }) {
return (
<article aria-live="polite" aria-busy="true">
<Markdown>{buffer}</Markdown>
</article>
);
}
Two independent mistakes stacked. First, aria-live on a container that grows real headings, lists, and links. Second, parsing Markdown during the stream, when ## Imp may become ## Implementation and then ## Implemented steps a moment later.
Screen readers do not owe you a stable cursor across those mutations. Headings are navigation landmarks. If you create and destroy them at token speed, you are animating the document outline. Would you animate the skip link too?
I also saw a quieter cousin of the same bug: an empty ## heading for one frame, announced as “heading level two” with no name. That is worse than silence, because it sounds like a broken page, not like a model that is still thinking.
Typed states, then one settled parse
The fix is not “more ARIA.” The fix is to stop treating a partial string as a document. I keep a tiny state machine so cancel, error, and settle cannot fight each other.
type TurnState =
| { status: "idle" }
| { status: "streamingPlain"; plain: string }
| { status: "settlingRich"; plain: string }
| { status: "ready"; plain: string; html: string; headingCount: number }
| { status: "cancelled"; plain: string }
| { status: "error"; message: string };
type TurnEvent =
| { type: "START" }
| { type: "TOKEN"; text: string }
| { type: "DONE" }
| { type: "CANCEL" }
| { type: "FAIL"; message: string }
| { type: "SETTLED"; html: string; headingCount: number };
During streamingPlain, the transcript is textContent. No Markdown. No headings. A separate, visually hidden live region may announce a throttled status, not the tokens themselves. After DONE, I parse once, count headings, swap in the rich article, and announce a single summary.
function statusAnnouncement(state: TurnState): string {
switch (state.status) {
case "streamingPlain":
return "Still answering.";
case "cancelled":
return "Answer cancelled.";
case "error":
return state.message;
case "ready":
return state.headingCount > 0
? `Answer ready, ${state.headingCount} headings.`
: "Answer ready.";
default:
return "";
}
}
Pointer users still see a preview if you want one. Put that preview outside the live region, or mark it aria-hidden="true" until settle, and keep a plain-text twin for AT. Dual rendering sounds heavy until you watch a heading rotor fill with ghosts.
Minimal reproduction
This single-file sketch is a proposed demo, not a production chat client. Wire onToken to your existing stream reader.
import { useEffect, useReducer, useRef } from "react";
function reduce(state: TurnState, event: TurnEvent): TurnState {
switch (event.type) {
case "START":
return { status: "streamingPlain", plain: "" };
case "TOKEN":
if (state.status !== "streamingPlain") return state;
return { ...state, plain: state.plain + event.text };
case "CANCEL":
if (state.status !== "streamingPlain") return state;
return { status: "cancelled", plain: state.plain };
case "FAIL":
return { status: "error", message: event.message };
case "DONE":
if (state.status !== "streamingPlain") return state;
return { status: "settlingRich", plain: state.plain };
case "SETTLED":
if (state.status !== "settlingRich") return state;
return {
status: "ready",
plain: state.plain,
html: event.html,
headingCount: event.headingCount,
};
default:
return state;
}
}
export function StreamingTurn({
parseMarkdown,
}: {
parseMarkdown: (plain: string) => { html: string; headingCount: number };
}) {
const [state, dispatch] = useReducer(reduce, { status: "idle" });
const liveRef = useRef("");
useEffect(() => {
const next = statusAnnouncement(state);
if (next && next !== liveRef.current) liveRef.current = next;
}, [state]);
useEffect(() => {
if (state.status !== "settlingRich") return;
const id = requestAnimationFrame(() => {
dispatch({ type: "SETTLED", ...parseMarkdown(state.plain) });
});
return () => cancelAnimationFrame(id);
}, [state, parseMarkdown]);
const busy = state.status === "streamingPlain" || state.status === "settlingRich";
return (
<section aria-labelledby="answer-label">
<h2 id="answer-label">Assistant</h2>
<div aria-live="polite" className="sr-only">
{liveRef.current}
</div>
{state.status === "streamingPlain" || state.status === "cancelled" ? (
<p data-plain-preview="">{state.plain}</p>
) : null}
{state.status === "ready" ? (
<article
aria-busy={false}
dangerouslySetInnerHTML={{ __html: state.html }}
/>
) : null}
{state.status === "error" ? (
<p role="alert">{state.message}</p>
) : null}
{state.status === "cancelled" ? (
<p>Answer cancelled. Partial text is still on screen.</p>
) : null}
<button type="button" disabled={!busy} onClick={() => dispatch({ type: "CANCEL" })}>
Stop
</button>
</section>
);
}
Yes, dangerouslySetInnerHTML still needs sanitization in a real app. The point of this demo is when headings exist, not how you bleach HTML.
Why a mocked three-token stream never caught it
Unit tests that await screen.findByText("hello") will pass forever. Heading injection needs a stream long enough to emit ##, a newline, a rewrite, and a cancel. Short mocks never build a rotor.
I needed a live completion that actually talks like a product answer: sections, lists, a stray heading, then more prose. Standing up a private GPU box for that client bug would have been theater. I pointed the same AbortController fetch reader at MonkeyCode’s free model access on their free server option so the stream was real enough to mutate the accessibility tree. Disclosure: This article was prepared as part of MonkeyCode's product outreach.
I am not quoting model names, quotas, or hardware I cannot verify from this chair. The workflow is boring on purpose: one throwaway prompt, one slow answer, Speech Viewer open, observer logging headings. If the server is shared, I still do not paste secrets into the composer. The subject here is the client’s document outline, not prompt hygiene.
A reader-owned ReadableStream is enough to drive the reducer:
async function readPlainStream(url, { signal, onToken }) {
const response = await fetch(url, { signal });
if (!response.ok || !response.body) {
throw new Error(`Stream failed with ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) break;
onToken(decoder.decode(value, { stream: true }));
}
}
Cancel must abort the fetch and dispatch CANCEL so the UI does not stay in streamingPlain with a dead socket. Have you ever tabbed to Stop, pressed it, and heard nothing because the live region only spoke tokens? That is the same family of bug, just quieter.
Keyboard, cancel, and recovery
Pointer-independent behavior I now treat as required for this control:
-
Tab reaches Stop while streaming, then returns to the composer after
ready,cancelled, orerror. - Escape cancels when focus is inside the transcript or composer, not only when Stop is focused.
- Stop is a real
<button>, not a clickable<div>. - After cancel, partial plain text remains, labeled as cancelled, and is not promoted to headings.
- After error,
role="alert"holds the message; retry is a separate control that starts a new turn.
I do not move focus into the rich article on settle. If someone is reading with the virtual cursor, stealing focus to an <h2> you just created is the original incident with extra steps.
Environment-specific QA matrix
Please reproduce with versions written down. My request is the exact transition, not a vibe check.
| Environment | Transition | Pass if |
|---|---|---|
| Windows + NVDA + Chrome | Stream emits ## + newline |
Speech Viewer does not say “heading” until settle |
| Windows + NVDA + Firefox | Cancel mid-heading | Rotor has no leftover empty headings |
| macOS + VoiceOver + Safari | Settle after a long answer | One “Answer ready” summary, headings appear only then |
| Keyboard only, any browser | Tab during stream | Stop remains reachable; focus does not jump to the transcript |
| Keyboard only | Escape during stream | State becomes cancelled; composer focuses |
| Screen magnifier | Settle | Layout shift from plain preview to rich article is visible but does not cover Stop |
If your team only dogs-foods with a mouse and a short mocked stream, this bug will look theoretical until a customer files it as “the chat is broken.”
Limitations, and who should not copy this blindly
This pattern is a client recovery mechanism, not a conformance certificate. I am not claiming WCAG, and a demo with dangerouslySetInnerHTML is not a sanitizer.
Do not use this approach when:
- You must expose live, navigable structure during generation (some tutoring and pair-programming UIs want that, and they need a different design).
- You stream untrusted HTML and think settle-time parsing makes it safe.
- You send private customer content to a shared free server just to debug CSS.
- You throttle the status live region so aggressively that cancel and error stay silent.
There is also a product tradeoff: sighted users who like watching Markdown snap into lists will see a plainer stream. I would rather give them a non-live visual preview than give AT users a haunted heading rotor. You can CSS the preview; you cannot CSS a screen-reader cursor back into a sentence.
What I would test next
The next failure I want on the matrix is list injection: a lone - becoming a list, then becoming a paragraph again. Same family, different landmark. Code blocks that open without closing before cancel are next after that.
If you ship streaming Markdown into a live region today, you already have a document that rewrites its own outline. Can your heading rotor survive one long answer? If you need a throwaway live stream to watch the accessibility tree mutate, MonkeyCode’s free model access and free server option are enough to reproduce the heading-injection path on a real completion rather than three fake tokens.
Top comments (0)