Last month I watched a keyboard-only user test a streaming chat UI I had built. Halfway through the model's answer, she pressed Escape — the universal "stop" gesture — and three things went wrong at once:
- The stream kept running, because only the Cancel button was wired to the abort logic.
- Focus silently dropped to
<body>, because the button she had activated gotdisabledmid-interaction and the browser yanked focus with it. - The screen reader said nothing. The partial answer just stopped growing, and there was no announcement that the stream had been cancelled — or that a Retry option now existed.
None of these are model problems. They're state management problems. So in this post I'll build a small, typed state machine for streaming chat where cancel, retry, and announcements are first-class states, not afterthoughts — and then I'll point it at a real streaming model instead of a mock, because mock streams never fail the way real ones do.
The state table comes first
Before any code, here's the contract the UI must honor. Every transition is explicit, every state has a defined focus behavior and announcement:
| State | Trigger | Visible UI | Focus | Announcement (live region) |
|---|---|---|---|---|
idle |
initial / reset | Send enabled, Cancel disabled | stays in composer | — |
connecting |
submit | spinner + "Contacting model…" | stays on Send (now labelled "Sending…") | "Sending your message" |
streaming |
first token | token output appends, Cancel enabled | stays put | "Response is streaming. Press Escape to stop." |
cancelling |
Esc / Cancel click | "Stopping…" | moved to composer textarea | "Cancelling the response" |
done |
stream end | partial/full answer kept, Retry hidden | stays put | "Response complete" |
cancelled |
abort finished | partial answer kept, labelled | already in textarea | "Response stopped. N characters kept." |
error |
network/HTTP failure | Retry button shown | moved to Retry button | assertive: "The response failed. Retry is available." |
Two rules fall out of this table and drive everything below:
- Focus is part of the state, not a side effect. Each state that removes an interactive element declares where focus goes next.
- Every state change is announced. Silent transitions are indistinguishable from a frozen page when you can't see the spinner.
A runnable single-file demo
Save this as index.html and open it in a browser. It runs against any streaming endpoint that returns newline-delimited text chunks; there's a mock mode so the keyboard and screen-reader behavior works with zero backend, and one constant to flip for a real model.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Accessible streaming chat — state machine demo</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 42rem; margin: 2rem auto; padding: 0 1rem; }
#stream { display: block; white-space: pre-wrap; min-height: 4rem;
border: 1px solid #ccc; border-radius: .5rem; padding: 1rem; margin: 1rem 0; }
#stream[data-state="streaming"] { border-color: #2563eb; }
#status { min-height: 1.5rem; font-size: .9rem; color: #444; }
.visually-hidden { position: absolute; width: 1px; height: 1px;
clip-path: inset(50%); overflow: hidden; white-space: nowrap; }
button[disabled] { opacity: .5; }
form { display: grid; gap: .5rem; }
.row { display: flex; gap: .5rem; }
textarea { font: inherit; padding: .5rem; }
</style>
</head>
<body>
<main>
<h1>Streaming chat, with a real state machine</h1>
<!-- polite: routine transitions. assertive: failures only. -->
<p id="status" role="status" aria-live="polite"></p>
<p id="alert" class="visually-hidden" role="alert"></p>
<output id="stream" data-state="idle" aria-label="Assistant response"></output>
<form id="composer">
<label for="prompt">Your message</label>
<textarea id="prompt" rows="3" required></textarea>
<div class="row">
<button type="submit" id="send">Send</button>
<button type="button" id="cancel" disabled>Cancel (Esc)</button>
<button type="button" id="retry" hidden>Retry last message</button>
</div>
</form>
</main>
<script type="module">
// ---------- configuration ----------
const USE_REAL_ENDPOINT = false;
// Any endpoint that streams newline-delimited text chunks works here.
const ENDPOINT = "http://localhost:8000/stream";
// ---------- the state machine ----------
const STATES = ["idle","connecting","streaming","cancelling","done","cancelled","error"];
let state = "idle";
let controller = null;
let lastPrompt = "";
const el = {
status: document.getElementById("status"),
alert: document.getElementById("alert"),
stream: document.getElementById("stream"),
prompt: document.getElementById("prompt"),
send: document.getElementById("send"),
cancel: document.getElementById("cancel"),
retry: document.getElementById("retry"),
};
function transition(next, detail = {}) {
if (!STATES.includes(next)) throw new Error(`Unknown state: ${next}`);
state = next;
el.stream.dataset.state = next;
// Focus and announcements are declared per state, not improvised.
const plan = {
idle: { announce: "", focus: null, send: ["Send", false], cancel: true, retry: true },
connecting: { announce: "Sending your message.", focus: null, send: ["Sending…", true], cancel: false, retry: true },
streaming: { announce: "Response is streaming. Press Escape to stop.",
focus: null, send: ["Sending…", true], cancel: false, retry: true },
cancelling: { announce: "Cancelling the response.",
focus: el.prompt, send: ["Send", true], cancel: true, retry: true },
done: { announce: "Response complete.", focus: null, send: ["Send", false], cancel: true, retry: true },
cancelled: { announce: `Response stopped. ${detail.kept ?? 0} characters kept.`,
focus: el.prompt, send: ["Send", false], cancel: true, retry: false },
error: { announce: "", focus: el.retry, send: ["Send", false], cancel: true, retry: false },
}[next];
if (next === "error") {
el.alert.textContent = `The response failed${detail.reason ? ": " + detail.reason : ""}. Retry is available.`;
} else {
el.alert.textContent = "";
}
el.status.textContent = plan.announce;
el.send.textContent = plan.send[0];
el.send.disabled = plan.send[1];
el.cancel.disabled = plan.cancel;
el.retry.hidden = plan.retry;
if (plan.focus) plan.focus.focus();
}
// ---------- transport ----------
async function* mockStream() {
const text = "This is a simulated streaming response. Press Escape while it is typing to test cancellation, focus, and announcements. ";
for (const word of text.repeat(3).split(" ")) {
await new Promise(r => setTimeout(r, 120));
yield word + " ";
}
}
async function* realStream(prompt, signal) {
const res = await fetch(ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt }),
signal,
});
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) return;
yield decoder.decode(value, { stream: true });
}
}
// ---------- orchestration ----------
async function run(prompt) {
lastPrompt = prompt;
el.stream.textContent = "";
transition("connecting");
controller = new AbortController();
let kept = 0, started = false;
try {
const source = USE_REAL_ENDPOINT
? realStream(prompt, controller.signal)
: mockStream();
for await (const chunk of source) {
if (controller.signal.aborted) break; // mock mode has no fetch to abort
if (!started) { transition("streaming"); started = true; }
el.stream.textContent += chunk;
kept += chunk.length;
}
transition(controller.signal.aborted ? "cancelled" : "done", { kept });
} catch (err) {
if (controller.signal.aborted || err.name === "AbortError") {
transition("cancelled", { kept });
} else {
transition("error", { reason: err.message });
}
}
}
function cancel() {
if (state !== "streaming" && state !== "connecting") return;
transition("cancelling");
controller?.abort();
}
// ---------- events: every action is pointer-independent ----------
document.getElementById("composer").addEventListener("submit", e => {
e.preventDefault();
const prompt = el.prompt.value.trim();
if (prompt) run(prompt);
});
el.cancel.addEventListener("click", cancel);
el.retry.addEventListener("click", () => run(lastPrompt));
document.addEventListener("keydown", e => {
if (e.key === "Escape") cancel();
});
transition("idle");
</script>
</body>
</html>
Try it with a screen reader (NVDA, JAWS, or VoiceOver): send a message, press Escape mid-stream. You should hear "Cancelling the response" followed by "Response stopped. N characters kept" — and focus lands in the textarea, ready to edit your prompt. That landing spot is deliberate: the most likely next action after a cancel is rephrasing.
Point it at a real model
Mock generators never drop connections, never stall for three seconds between chunks, and never return HTTP 429 mid-sprint. To exercise the error and mid-stream-stall paths honestly, flip USE_REAL_ENDPOINT to true and point ENDPOINT at a real streamed model. You need one you can afford to hammer during testing — cancellation testing means deliberately abandoning requests, and you don't want to ration that.
For my own runs I used the free model access on MonkeyCode's free server option as the streaming backend — convenient here because a throwaway test loop doesn't need a paid quota, and the demo only assumes "POST prompt, read a streamed body," so swapping providers is a one-line change.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
The important part isn't which backend you use — it's that the state machine behaves identically under a real stream's failure modes: slow first token (does the UI stay in connecting with a sensible announcement?), mid-stream network cut (does focus land on Retry?), and immediate cancel-before-first-token (does the machine pass cleanly through connecting → cancelling → cancelled without flickering streaming?).
QA matrix: the transitions that actually break
Don't just test that states render — test the transitions, with real assistive technology:
| Transition | What to verify | Where it usually breaks |
|---|---|---|
idle → connecting |
"Sending your message" announced once | announcement fires per keystroke instead |
connecting → cancelling |
Esc works before first token | Esc only bound to the Cancel button's click handler |
streaming → cancelling |
focus moves to textarea | disabled button swallows focus to <body>
|
streaming → error |
assertive alert; focus on Retry; Retry is keyboard-reachable | Retry rendered as a <div> with a click handler |
cancelled → idle (next send) |
live regions re-announce on the next cycle | same text re-set without clearing, so nothing is announced |
any → done
|
final text remains readable; not inside a live region that re-reads it all | answer placed inside aria-live, dumping the full response |
Run at minimum: Chrome + NVDA on Windows, Safari + VoiceOver on macOS, and one mobile pairing (Android + TalkBack or iOS + VoiceOver), since virtual-cursor and touch-exploration interact with live regions differently.
Limitations, and who shouldn't use this as-is
- This is a single-file teaching demo, not production code. There's no conversation history, no markdown rendering, no rate-limiting, and no auth.
- The state machine handles one in-flight request. Multi-turn or parallel tool-call UIs need a proper machine library (XState or similar) so nested states stay exhaustive.
- A free server tier is for development and failure-path testing, not production load. Check the current terms and limits before relying on it for anything user-facing, and keep the endpoint swappable — that's why the demo hides it behind one constant.
- Live-region behavior varies across browser/AT combinations. The QA matrix above is the floor, not the ceiling.
If your stream aborts cleanly but your users never hear it stop, you don't have a cancellation feature — you have a silent one. Steal the state table, wire announcements into your transitions, and test the failure paths against a real stream. If you hit a transition that breaks with a specific browser/OS/screen-reader combo, drop the versions and the exact transition in the comments — that's the most useful bug report there is.
Top comments (0)