> How to build a Vue or Nuxt AI chat UI: fetch streams, abort and retry, safe markdown rendering, aria-live announcements, keyboard support and reduced motion.
>
> Web page: https://balazscsorba.com/blog/vue-ai-chat-ui-streaming-accessibility · Language: English · Also available in: [Deutsch](https://balazscsorba.com/de/blog/vue-ai-chat-ui-streaming-accessibility.md) · [Magyar](https://balazscsorba.com/hu/blog/vue-ai-chat-ui-streaming-accessibility.md)
> Author: Balázs Csorba · Published: 2026-10-17 · Keywords: vue ai chat ui, nuxt llm streaming, sse vs fetch streaming, aria-live streaming text, accessible chat interface, abort fetch stream vue, dompurify markdown llm, role log chat, reduced motion chat ui, wcag 2.2 chat, screen reader streaming

[Blog](https://balazscsorba.com/blog)/Web engineering

# Streaming AI chat in Vue and Nuxt: accessible by design

How to build a Vue or Nuxt AI chat UI: fetch streams, abort and retry, safe markdown rendering, aria-live announcements, keyboard support and reduced motion.

[Balázs Csorba](https://balazscsorba.com/about)·October 17, 2026·11 min read

-   Vue
-   Nuxt
-   Accessibility
-   Streaming
-   AI chat

![A message card with a careful caret, a dotted line opening into ripples and a check.](https://balazscsorba.com/images/blog/vue-ai-chat-ui-streaming-accessibility/cover.webp?v=3a46026edb)

## Key takeaways

-   EventSource is built for one-way notifications; a chat that POSTs its messages and needs headers is better off reading the fetch response body as a stream.
-   Abort is user intent, not a failure: keep the partial answer and never paint the state red after a Stop press.
-   Model output is untrusted input: a safe-by-default markdown parser plus DOMPurify, with nothing touching the string after sanitizing.
-   Put the transcript in role=log and transient states in role=status; announce completions, not tokens.
-   Keyboard support, 24 pixel target sizes and prefers-reduced-motion belong to the same definition of done as the stream parser.

On this page

1.  [Choose the transport before you choose the component](https://balazscsorba.com/#streaming-choices)
2.  [Reading a fetch stream in a Vue composable](https://balazscsorba.com/#reading-the-stream)
3.  [Abort is a feature, not an error](https://balazscsorba.com/#abort-and-retry)
4.  [Model the connection as four states](https://balazscsorba.com/#stream-state-model)
5.  [Render markdown without handing over the DOM](https://balazscsorba.com/#rendering-markdown-safely)
6.  [Announce the answer, not every token](https://balazscsorba.com/#announcing-streams)
7.  [Keyboard and focus: the composer is the app](https://balazscsorba.com/#keyboard-and-focus)
8.  [Respect prefers-reduced-motion](https://balazscsorba.com/#reduced-motion)
9.  [Testing the chat UI like a user, not like a linter](https://balazscsorba.com/#testing-the-chat-ui)
10.  [Sources](https://balazscsorba.com/#sources)

Listen to this article

0:000:00

A chat box is the easiest LLM feature to demo and one of the hardest to ship well. The answer arrives token by token, the user watches the text grow, and inside that loop sit three separate obligations: keep the stream honest when the network misbehaves, keep model output from executing in the browser, and keep the interface usable for someone who never sees the text appear. I build Vue and Nuxt applications for B2B clients in Austria, and in every chat review the streaming works and the accessibility does not.

What follows is a build order rather than a library review. We pick a transport, read it in a Vue composable with a reader and an `AbortController`, model the connection as four states, sanitize the markdown the model produced, and then do the part that usually gets skipped: announcing new content to screen readers, keeping the composer keyboard-operable and honoring `prefers-reduced-motion`. The basics of streaming out of a Nuxt server route are in my earlier article on [shipping LLM features in Nuxt](https://balazscsorba.com/blog/nuxt-llm-features-ai-sdk-streaming); this one goes deeper on the interface around the stream.

## Choose the transport before you choose the component

Two transports dominate chat interfaces. The browser-native `EventSource` speaks server-sent events: the server answers with the `text/event-stream` content type and UTF-8 messages built from `event`, `data`, `id` and `retry` fields, and the browser takes care of reconnection for you. That resilience has two sharp edges in a chat. The connection is one-way, so it cannot carry the POST request that submits your message history, and reconnection is automatic: after a network error the browser retries without asking, which is exactly wrong for a one-shot token stream where a silent retry could duplicate a half-rendered answer.

There is also an infrastructure limit worth knowing before you commit. Over HTTP/1.1, browsers cap open connections at six per browser and domain, a limit marked as won't fix in both Chrome and Firefox, while with HTTP/2 the number of simultaneous streams is negotiated between browser and server and defaults to 100. If your platform still terminates connections over HTTP/1.1, a handful of open chat tabs already sits at the ceiling.

Approach

Direction

Auth

Reconnect

What you still handle

EventSource (SSE)

server to client only

GET, no request body

automatic via the retry field

the entire send path

fetch plus ReadableStream

request body and response stream

full headers and body

none, you decide

parsing, abort, error states

AI SDK useChat

POST with a UI message stream

configurable headers and body

handled by the transport

state, rendering, accessibility

For a chat that submits a message list, needs an `Authorization` header and must stop the moment the user presses Stop, the fetch response body read as a `ReadableStream` is the honest default, and it is what the AI SDK's `useChat` hook does under the hood, including for its Vue package. The rest of this article builds that loop by hand, so that when you do reach for the hook you know what it is doing for you and where its state model ends and your accessibility work begins.

## Reading a fetch stream in a Vue composable

A fetch response exposes its body as a `ReadableStream`. You can lock it with `getReader()` and read in a loop, or skip the reader and iterate the chunks directly: the Streams API implements the async iterable protocol, so `for await...of` over the response body works out of the box. MDN notes two details that matter in production: iterating locks the stream so nothing else can consume it, and leaving the loop early cancels the stream unless you pass `preventCancel`. For the bytes, pipe the body through a `TextDecoderStream`, the streaming form of TextDecoder that converts a binary encoding into a stream of strings.

```
<script setup lang="ts">
import { ref, onBeforeUnmount } from "vue"

type Status = "ready" | "streaming" | "error"
const reply = ref("")
const status = ref<Status>("ready")
let controller: AbortController | null = null

async function send(text: string) {
  controller?.abort()
  controller = new AbortController()
  status.value = "streaming"
  reply.value = ""
  try {
    const res = await fetch("/api/chat", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ message: text }),
      signal: controller.signal,
    })
    if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`)
    const reader = res.body
      .pipeThrough(new TextDecoderStream())
      .getReader()
    let leftover = ""
    for (;;) {
      const { done, value } = await reader.read()
      if (done) break
      leftover += value
      const events = leftover.split("\n\n")
      leftover = events.pop() ?? ""
      for (const ev of events) {
        if (ev.startsWith("data: ")) reply.value += ev.slice(6)
      }
    }
    status.value = "ready"
  } catch (err) {
    // a Stop press is not a failure
    status.value = (err as Error).name === "AbortError" ? "ready" : "error"
  }
}

onBeforeUnmount(() => controller?.abort())
</script>
```

Two lines in that loop deserve a comment. The leftover buffer exists because network chunks are not aligned with your protocol: a single `data` event can be split across two reads, so you process only complete, blank-line-separated events and keep the rest for the next iteration. And the catch block treats an `AbortError` as a normal ending, because a Stop press is user intent, not an error. Everything else, including a non-2xx status detected before the stream starts, moves the state to error where the interface can offer a retry.

## Abort is a feature, not an error

`AbortController` is the standard tool for this: its `abort()` method aborts fetch requests, the consumption of response bodies and streams. Wire one controller per conversation turn, abort the previous request before starting the next, and abort on unmount, so that navigating away during a long generation does not leave the server streaming into a dead tab. The subtle part is the interface around it: the partial answer that already arrived is valid text, so keep it in the transcript, mark the message as stopped and never paint the state red.

```
// stop is user intent: abort and keep whatever arrived
function stop() {
  controller?.abort()
  status.value = "ready"
}

// retry resends the last user message, not the last wire payload
async function retry() {
  const last = messages.value.at(-1)
  if (last?.role === "user") await send(last.text)
}
```

Retry belongs in the flow as a normal button that takes focus when it appears, not as a hidden gesture. The AI SDK exposes exactly this pair: `stop` aborts the fetch request, `regenerate` asks the model to reprocess the last message, and `status` gates which of them is enabled. When something does fail, show a short generic message: the AI SDK documentation recommends a plain something-went-wrong text instead of surfacing the raw error, which keeps server details out of a public interface, while the real error goes to your logs where you can act on it.

Two exits matter: Stop must be indistinguishable from success in the state machine, while a network failure must land on a retry action, not on a stack trace.

## Model the connection as four states

Derive every visible control from a single status value. The AI SDK's `useChat` hook returns a status with four values: `submitted` means the request is on the wire and the stream has not started, `streaming` means chunks are arriving, `ready` means the response finished and a new message can be sent, and `error` means the request failed. A send button disabled while not ready, a Stop button only while submitted or streaming, and retry only in the error state is a state machine you can test without a screen reader, and it is also the machine that assistive technology will report through your status region.

Throttling belongs to the state model, not to a performance backlog. `useChat` documents a throttle option for Vue that batches reactive message updates (the documentation uses 50 milliseconds) while stream processing and event callbacks stay immediate, and it guarantees that the latest messages are still published before the chat reaches a terminal state. Without it, a fast model mutates your reactive array dozens of times per second and every consumer re-renders along with it.

Vue helps you on the render side: when you mutate reactive state, DOM updates are not applied synchronously but buffered until the next tick, so several chunk writes in one tick collapse into a single patch. Do not measure the transcript DOM in the same tick you appended a chunk; await `nextTick()` first. This is also where the accessibility and performance budgets meet, because fewer renders per keystroke keep the interaction next paint of the whole page honest, which is exactly what the [Core Web Vitals guidance for Nuxt sites](https://balazscsorba.com/blog/nuxt-core-web-vitals-performance) is about.

## Render markdown without handing over the DOM

Model output is user-influenced input. A prompt can ask the model to emit a script tag, an iframe or a javascript link, and anyone who can steer the conversation can try it for real, so this is the same threat model as any other untrusted HTML. The safe combination is a markdown parser that is safe by default plus a sanitizer on its output. markdown-it advertises exactly that: it follows CommonMark and it is safe by default, which means raw HTML in the markdown is escaped rather than passed through unless you opt in. DOMPurify then mops up whatever the parser produced.

```
import MarkdownIt from "markdown-it"
import DOMPurify from "dompurify"

// html stays false: raw HTML in model output is escaped
const md = new MarkdownIt({ linkify: true })

export function renderAssistantMarkdown(src: string): string {
  const html = md.render(src)
  return DOMPurify.sanitize(html, {
    USE_PROFILES: { html: true }, // replies need no SVG or MathML
    ADD_ATTR: ["target"],
  })
}
```

**Sanitize last, and mean it**

DOMPurify's own documentation warns that if you sanitize first and modify the markup afterwards, you can easily void the effects of sanitization. So no syntax highlighter, link rewriter or copy-button helper may touch the string after `sanitize()`; do that work as Vue components or data attributes on top of the sanitized output. Two more footguns from the same README: the default configuration also permits SVG and MathML, which a chat reply never needs, and if you sanitize on the server, do not use happy-dom, which the project states is not considered safe. The wider moderation story lives in my piece on [LLM output guardrails](https://balazscsorba.com/blog/llm-output-guardrails-moderation); rendering is the last line of that defense, not the first.

Keep the allow-list narrow and the pipeline one-directional: markdown in, sanitized HTML out, and only then rendered with `v-html`. Links get their target and rel attributes from your renderer, never from the model, and anything that looks like a tool call or a product card is drawn by a Vue component you control. A screen reader also meets this content, so keep the generated markup semantic: real headings, real lists, real code elements, because assistive technology navigates by exactly those structures.

## Announce the answer, not every token

Here is the part most implementations skip. WCAG 2.2 became a W3C Recommendation on 5 October 2023, and its success criterion 4.1.3 Status Messages, level AA, requires that status messages be programmatically determinable through role or properties so assistive technologies can present them without the element receiving focus. The WAI techniques point to `role=status` for results and waiting states, `role=log` for sequential updates and `role=alert` for errors. A chat transcript is the textbook case for role=log: MDN describes it as a live region where new information is added at the end in meaningful order, which is precisely how a conversation grows.

Three details decide how that feels in practice. A role=log region has an implicit `aria-live` value of polite, so announcements wait until the user is idle, and an implicit `aria-atomic` of false, so only the delta is spoken. The region must have an accessible name, so give it `aria-label` or `aria-labelledby`. And the transcript must contain the finished message, because a screen reader user who finds the log later expects the answer to be there as readable text.

The buffer is the accessibility feature: the reader hears one finished answer, not fifty token updates.

Keep the announcement surface small: one status line for transient states such as thinking, stopped and error, and the log region for content. Because polite live regions announce only what changed, publishing the assistant message once, after the stream ends, produces a single clean announcement, while publishing every token range would produce a stutter. The WAI understanding document for 4.1.3 warns from the other direction too: an application can become too chatty, so test the announcement rhythm with real screen reader users instead of guessing.

```
<section aria-labelledby="chat-heading">
  <h2 id="chat-heading">Support chat</h2>

  <!-- transcript: finished messages announced politely, as a delta -->
  <ol role="log" aria-live="polite" aria-label="Chat messages">
    <li v-for="m in messages" :key="m.id">
      <span class="who">{{ m.role === 'user' ? 'You' : 'Assistant' }}</span>
      <div v-html="m.html"></div>
    </li>
  </ol>

  <!-- transient state: announced once, never focused -->
  <p role="status">{{ statusText }}</p>

  <form @submit.prevent="send">
    <label for="chat-input">Message</label>
    <textarea id="chat-input" v-model="draft"
      @keydown.enter.exact.prevent="send"></textarea>
    <button type="button" v-if="status === 'streaming'" @click="stop">Stop</button>
    <button type="submit" :disabled="status === 'streaming'">Send</button>
  </form>
</section>
```

**Announce completions, not tokens**

A streaming answer changes every few tens of milliseconds. If each change reaches a live region, a screen reader user hears a constant stutter of partial sentences and never forms a picture of the answer. Buffer the chunks and publish the message to the log region when the stream ends or at sentence boundaries; MDN also notes that setting `aria-atomic` to true makes assistive technology read the entire region again, which is useful for short status text and noisy for a long reply. The wrong version of this feature is worse than none at all, so ship the buffered one.

## Keyboard and focus: the composer is the app

Everything in a chat must work from the keyboard alone, and the composer is where that usually breaks. Keep focus in the textarea after sending, so a user who fires question after question never has to Tab back. Enter sends and Shift+Enter inserts a newline, but only intercept the key on the textarea itself, never on a wrapping div. Send and Stop are real buttons, so Enter and Space work for free, and the Stop button that appears mid-stream must not steal focus from someone still typing. Nothing should be reachable only by hovering over it.

Two criteria in WCAG 2.2 are specific to this layout. Target Size (Minimum), 2.5.8 at level AA, asks for pointer targets of at least 24 by 24 CSS pixels or sufficient spacing around smaller ones, which kills the temptation to draw Stop as a tiny inline glyph. Focus Not Obscured (Minimum), 2.4.11 at level AA, requires that a component receiving keyboard focus is not entirely hidden by author-created content, which is exactly the risk of a sticky composer bar above a scrolling transcript: add scroll padding and scroll margin so that links inside messages stay visible when they receive focus.

## Respect prefers-reduced-motion

`prefers-reduced-motion` detects the setting people enable to minimize non-essential motion, and MDN names scaling and panning of large objects as classic triggers of discomfort for users with vestibular motion disorders. A chat is full of exactly those effects: pulsing typing dots, a blinking caret, a smooth auto-scroll that glides the transcript up with every chunk. The fix is cheap and it is CSS: keep the animation for people who want it, and replace it with an instant state change for everyone else.

```
.typing-dot { animation: pulse 1.1s ease-in-out infinite; }
.chat-log { scroll-behavior: smooth; }

@media (prefers-reduced-motion: reduce) {
  .typing-dot { animation: none; }
  .chat-log { scroll-behavior: auto; }
}
```

Two opinions worth defending in a review. First, auto-scroll should jump rather than glide when motion is reduced, and it should stop entirely the moment the user scrolls up to read something, because fighting the reader is worse than any animation. Second, never gate the only signal of activity behind motion: if the typing indicator is the sole proof that the model is working, a user with reduced motion has no proof at all. If you need the preference on the server, there is a user-agent client hint for it, `Sec-CH-Prefers-Reduced-Motion`.

## Testing the chat UI like a user, not like a linter

Test the way the failure reaches real users. Do one keyboard-only pass: Tab to the composer, Enter to send, confirm that Stop appears and is reachable while the answer streams, and that retry takes focus when it shows up. Do one screen reader pass with VoiceOver on macOS and NVDA on Windows, the two that matter for my Austrian and Hungarian clients: the reply should be announced once, the error should be announced at all, and nothing should repeat itself while tokens stream. Run an automated axe scan in CI to catch missing names and roles, but treat it as a floor, not as evidence.

Then add the three regression tests you would want at two in the morning. First, kill the connection mid-stream and assert that the UI lands in the error state with a retry action and no unhandled rejection. Second, press Stop and assert that the state is not error and that the partial answer survives. Third, send a message that makes the model emit a script tag and assert that it renders as text. These three tests cover most of what this article describes and they run in any headless browser. If you sell into the EU, the [European Accessibility Act](https://balazscsorba.com/blog/european-accessibility-act-b2b-shop) already treats accessible interfaces as a commercial requirement, so this work is not optional either.

## Sources

1.  [MDN: Using server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events)
2.  [MDN: AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController)
3.  [MDN: ReadableStream](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream)
4.  [MDN: TextDecoderStream](https://developer.mozilla.org/en-US/docs/Web/API/TextDecoderStream)
5.  [MDN: ARIA log role](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/log_role)
6.  [W3C: Understanding SC 4.1.3 Status Messages](https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html)
7.  [W3C WAI: What's new in WCAG 2.2](https://www.w3.org/WAI/standards-guidelines/wcag/new-in-22/)
8.  [MDN: prefers-reduced-motion](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion)
9.  [DOMPurify README](https://github.com/cure53/DOMPurify)
10.  [markdown-it README](https://github.com/markdown-it/markdown-it)
11.  [AI SDK docs: useChat](https://ai-sdk.dev/docs/ai-sdk-ui/chatbot)
12.  [Vue docs: nextTick](https://vuejs.org/api/general.html#nexttick)

## Frequently asked questions

Should the streaming text itself live in an aria-live region?

No. Publish the finished message into the log region and use a small status line for thinking, stopped and error. A live region that receives token updates turns a screen reader into a stutter machine; publish at completion or at sentence boundaries and let the transcript hold the full text for the reader to explore.

Why not just use EventSource for the response?

Because it is one-way and its reconnection is automatic. EventSource only receives, so the POST that submits your messages still needs fetch, and the built-in retry can silently replay a stream you have already half-rendered. A fetch response body gives you request and response in one call and leaves abort under your control.

Is markdown-it alone not enough?

It escapes raw HTML by default, but a sanitizer is cheap insurance. Linkified URLs, plugin output and future options can widen the surface, and DOMPurify defends against parser-mutation, namespace and clobbering tricks that hand-written filters miss. Run it last, and never modify the string after sanitizing.

How do I keep the UI responsive while tokens stream in?

Batch the reactive updates. The AI SDK exposes a throttle option for Vue, and Vue already buffers DOM patches until the next tick, so appending a chunk stays cheap as long as you avoid heavy child components and scroll math in the same tick. Fewer renders per second also protect the interaction next paint of the page.

Do I really have to test with a screen reader?

Yes, and more cheaply than you fear. Five minutes with VoiceOver on macOS or NVDA on Windows surfaces the two classic bugs here: a transcript that is never announced and an error state that is visually obvious but silent. Automated checks catch missing roles; they do not tell you whether the announcements are usable.

Written by Balázs Csorba

Senior fullstack & AI engineer in Styria, Austria – 10+ years of Vue, Nuxt, Node.js and PHP, now building tooling for AI agents.

[Vue.js & Nuxt development →](https://balazscsorba.com/expertise/vue-nuxt-developer)[About me →](https://balazscsorba.com/about)

## More articles

-   [Schema.org structured data for B2B shops: prices behind logins, units and variants](https://balazscsorba.com/blog/schema-org-structured-data-b2b-shop)
-   [An AI assistant for B2B shop support: tools, permissions and the human handoff](https://balazscsorba.com/blog/ai-assistant-b2b-shop-customer-support)
-   [An LLM translation pipeline for i18n: glossary, placeholders and review](https://balazscsorba.com/blog/llm-translation-pipeline-i18n)
-   [Multilingual SEO with Nuxt i18n: URL strategy, hreflang, canonicals and sitemaps](https://balazscsorba.com/blog/nuxt-i18n-multilingual-seo-hreflang)

## Sounds like what you need?

Tell me about your project or role – I’d love to hear from you.

[Get in touch](mailto:contact@balazscsorba.com) [Connect on LinkedIn](https://www.linkedin.com/in/balazs-csorba)
