Skip to content

Accessibility in AI Interfaces

6 min read · updated August 3, 2026

Streaming text into an ARIA live region is the accessibility failure almost every AI product ships, and it is not carelessness. The streaming interaction and the live-region model make incompatible assumptions, and there is no attribute that reconciles them.

The conflict, stated precisely

A token stream mutates its container many times a second for tens of seconds. A live region is a promise to assistive technology that changes to this container are worth announcing. Put those together and the AT is asked to announce a region that never stops changing.

Both available politeness settings fail, in opposite directions:

  • aria-live="assertive" interrupts whatever is currently being spoken. Under a stream, each mutation interrupts the announcement of the previous one, so the user hears the beginnings of many fragments and the whole of nothing. It also makes the interface impossible to navigate, because the user’s own exploration is interrupted too.
  • aria-live="polite" waits for a pause. Announcements queue, and depending on the AT and browser the queue is either flushed — losing content — or worked through, in which case the speech falls further behind the text with every chunk. Some combinations re-announce the entire region on each mutation, which turns a 400-word answer into hundreds of increasingly long re-readings.

This is not a bug in any one implementation. Live regions were designed for discrete updates — a status changed, a result count updated — and a token stream is a continuous one. Nothing in the specification contemplates a region that mutates continuously for thirty seconds, so there is no correct value to set.

What a live region actually does

Worth being exact, because the fix depends on it. A live region is a container the AT observes; on mutation, the AT computes what changed and queues an announcement. aria-atomic controls whether the whole region or only the changed part is announced. aria-relevant controls which mutation types qualify. Politeness controls queue behaviour relative to current speech.

What none of those attributes provide is rate control. There is no “announce this region at most once every N seconds”, and no “announce only when it stops changing”. Since the problem with a stream is entirely one of rate, no combination of the available attributes solves it — which is why the fix has to be in the application rather than in the markup.

The commit pattern

Decouple the visual stream from the accessible announcement. They are serving different users with different needs, and the mistake is assuming one DOM node has to do both jobs.

  • Stream visually, outside the accessibility tree. The incremental container is aria-hidden. Sighted users get the perceived-latency win; the AT is not asked to narrate a firehose.
  • Announce status, politely, at human intervals. A small role="status" region carrying “Generating response”, then a periodic reassurance if the wait is long. This is a discrete update, which is what live regions are for.
  • Commit the finished answer once. On completion, write the full text into a normal, non-live container, and announce that it is ready. The user then reads it with their own navigation — by line, by sentence, by paragraph — which is how they read everything else and is far better than being read at.
  • Offer chunked commits as a preference. Some users do want progressive access. Committing paragraph by paragraph is a reasonable middle setting; per-token never is.

The insight worth keeping: for a screen-reader user, streaming has nearly none of the benefit it has visually. A sighted user skims arriving text and starts extracting value immediately. Serial audio cannot skim. The value of streaming was always the ability to read ahead of the generation, and that ability does not exist here — so reproducing the visual behaviour faithfully is reproducing a benefit that does not transfer, at the cost of a mechanism that actively breaks.

Implementing it

function Answer({ text, state }) {
  // state: "waiting" | "streaming" | "done" | "error"
  const streaming = state === "streaming";

  return (
    <>
      {/* 1. Status: discrete, polite, low frequency. */}
      <div role="status" aria-live="polite" className="sr-only">
        {state === "waiting"   && "Generating response"}
        {state === "done"      && "Response ready"}
        {state === "error"     && "Response failed"}
      </div>

      {/* 2. The visual stream, hidden from the a11y tree. */}
      {streaming && (
        <div aria-hidden="true" className="prose">
          {text}
        </div>
      )}

      {/* 3. The committed answer: a normal region, announced once
             by the status above, then navigated by the user. */}
      {state === "done" && (
        <div className="prose" tabIndex={-1} ref={focusOnCommit}>
          {text}
        </div>
      )}
    </>
  );
}

Two details that matter more than they look. The status region must exist in the DOM before its content changes — a live region added and populated in the same tick is frequently not announced at all. And moving focus to the committed answer must be a preference rather than a default: focus movement is helpful for a user waiting for the result and hostile to one who has navigated elsewhere during the wait.

The problems that are not screen readers

  • Cancel must be keyboard-reachable at all times. During a thirty-second generation the cancel control is the most important thing on screen. If it only appears after a delay, appears below a growing block of text, or is reachable only by mouse, the user has no way out.
  • Typewriter effects need prefers-reduced-motion. Continuously appearing and reflowing text is motion. Honour the preference by committing in larger chunks or all at once, which is also the better behaviour for anyone who finds moving text hard to read.
  • Reserve the layout. Content that grows pushes everything below it down, repeatedly, for thirty seconds. That is hostile to a magnifier user tracking a specific region, and to anyone with a motor impairment trying to hit a moving target.
  • Never encode a state in colour alone. The grounded/unverified markers from confidence display and the citation states from citation UI need a shape, an icon or text as well.
  • Give a control over verbosity. Long hedged output is a cognitive-accessibility problem, and it is also just worse for everyone. A “shorter” control is one of the few accessibility features that is simultaneously a cost control.

Two of those deserve underlining because they are not adaptations of existing guidance — they are new obligations created by the model itself. Generated output has no fixed length, so a layout that was stable with static content is not stable here; and generated output has no fixed reading level, so text that was written to a standard is now written to whatever the model produced. Both are properties of the content rather than of the markup, which means no audit tool will find them and no component library will fix them.

Which points at the honest summary of this page: accessibility for AI interfaces is not mostly about ARIA. The live-region conflict is real, it is the sharpest technical problem here, and it is solved by the commit pattern in about thirty lines. Everything after that — length, reading level, layout stability, the ability to stop — is about designing for content you did not write, and it is the part that stays hard.

Accessibility in AI Interfaces · Multigrid