DEV Community

babycat
babycat

Posted on

An Accessible Streaming AI Interface Has More Than a Loading Spinner

Streaming text makes an AI feature feel responsive, but it also creates an awkward interface: the result is visible before it is complete, changes many times per second, and may fail after the user has already started reading.

An accessible implementation needs more than a spinner and an aria-live attribute. It needs an understandable state model.

Give every state a user-facing meaning

Start with explicit states:

type GenerationState =
  | { name: "idle" }
  | { name: "submitting" }
  | { name: "streaming"; partialText: string }
  | { name: "complete"; text: string }
  | { name: "cancelling"; partialText: string }
  | { name: "cancelled"; partialText: string }
  | { name: "failed"; partialText: string; retryable: boolean };
Enter fullscreen mode Exit fullscreen mode

This prevents contradictory UI such as an enabled Generate button beside a result that is still arriving. It also forces product decisions: Does partial text remain after cancellation? Can a failed request be retried? Is a streamed draft safe to copy before validation?

Separate visual output from announcements

Do not place the rapidly changing token stream inside an assertive live region. A screen reader may attempt to announce every update, producing noise that is impossible to follow.

Use two regions:

<div id="generation-status" role="status" aria-live="polite"></div>

<section aria-labelledby="result-heading" aria-busy="true">
  <h2 id="result-heading">Generated draft</h2>
  <div id="result-output"></div>
</section>
Enter fullscreen mode Exit fullscreen mode

Update the visible output as text arrives. Update the status region only at meaningful transitions:

  • “Generating draft.”
  • “Draft complete.”
  • “Generation cancelled. Partial text is available.”
  • “Could not generate the draft. Your input was preserved.”

Set aria-busy="false" when the operation reaches a terminal state. The WAI-ARIA specification defines the status role as a live region with polite announcements; reserve interruptive alerts for information that genuinely needs immediate attention.

Keep focus under the user’s control

Starting generation should not move focus to the output. The user may want to review or edit the input while waiting. When generation completes, announce completion and provide a clear way to reach the result.

Move focus only when it supports recovery, for example:

  • to a validation error after submission;
  • to an error summary after a failed operation when the current control no longer explains what happened;
  • to a confirmation dialog for a consequential next action.

Do not focus every new paragraph or token. Dynamic content can update without taking the keyboard away.

Implement real cancellation

The Cancel button should be a button, stay keyboard reachable, and describe its operation:

<button type="button" id="cancel-generation">
  Cancel generation
</button>
Enter fullscreen mode Exit fullscreen mode

In the browser, connect it to an AbortController:

const controller = new AbortController();

const response = await fetch("/api/drafts", {
  method: "POST",
  body: JSON.stringify(payload),
  signal: controller.signal,
  headers: { "content-type": "application/json" },
});

cancelButton.addEventListener("click", () => controller.abort());
Enter fullscreen mode Exit fullscreen mode

AbortController stops the browser-side fetch, as documented by MDN’s AbortController. If the backend starts a separate job, send a cancellation request there too; disconnecting the browser does not necessarily stop server work.

While cancellation is pending, change the button label to “Cancelling…” and disable repeated activation. After cancellation, state clearly whether partial output was kept and whether it has been validated.

Preserve the input and the partial result

A generic “Something went wrong” message creates unnecessary work. A useful error state answers:

  1. What happened?
  2. Was the user’s input saved?
  3. Is partial output available?
  4. Can the operation be retried safely?
  5. Is there another path to complete the task?

Example:

The draft stopped before completion. Your source text and the partial draft
are still here. Try again, or continue editing the partial draft manually.
Enter fullscreen mode Exit fullscreen mode

When retrying, avoid deleting the partial result until the replacement has started successfully. If a retry might create a duplicate action, the server needs an idempotency key; the UI alone cannot guarantee safety.

Label uncertainty at the right level

A permanent banner saying “AI may be wrong” quickly becomes wallpaper. Put guidance next to the decision it affects:

  • mark streamed output as “Draft—still generating”;
  • show source links beside claims when the feature has retrieval evidence;
  • use an editable field for content the user is expected to review;
  • require explicit confirmation before publishing, sending, or changing data;
  • distinguish “no source found” from “the request failed.”

The interface should make correction normal, not frame it as an exceptional failure by the user.

Test more than the happy path

Run the component with:

  • keyboard-only navigation;
  • a screen reader at normal and very fast stream rates;
  • reduced motion enabled;
  • 200% and 400% zoom;
  • a slow response, an empty response, malformed data, and a mid-stream error;
  • cancellation before the first token and after substantial text;
  • a retry after reconnecting to the network.

Also test the DOM announcement sequence. A snapshot can confirm labels and roles, but listening to the interaction is what reveals repeated or interrupted announcements.

Streaming is a presentation technique, not a complete interaction model. When status, output, focus, cancellation, and recovery are designed separately, the interface can feel fast without becoming confusing or exhausting.

Top comments (0)