AI Response
The answer itself: streamed text where each word resolves out of a blur as it
arrives, and every [n] marker in the string becomes a citation pill.
The important behaviour is when words animate. They animate as they arrive, not on a fixed timer — the component remembers how many tokens it had on its last render and animates only the new ones. A timer-driven typewriter drifts out of step with the real stream and starts lying about how fast the model is answering.
Whitespace and bare punctuation stay as plain text. Wrapping a comma in its own inline block would let the line break between a word and the punctuation that belongs to it. Pair it with AI Citation when a marker needs a preview card rather than just a link.
Usage
Set text to the answer so far and append tokens to it as they arrive — the
component animates the new words by itself. Pass citations to turn [n] markers
into pills.
<template>
<div class="w-full max-w-lg rounded-2xl border border-default p-6">
<MAiResponse
:citations="citations"
:is-streaming="streaming"
:text="text"
/>
<div class="mt-4 flex items-center gap-3">
<UButton
:label="streaming ? 'Pause' : 'Resume'"
size="sm"
variant="soft"
@click="streaming = !streaming"
/>
<UButton
label="Replay"
size="sm"
variant="ghost"
@click="replay"
/>
</div>
</div>
</template>
<script setup lang="ts">
type Citation = {
id: string
index: number
title: string
url?: string
}
const citations: Citation[] = [
{ id: 'c-1', index: 1, title: 'Theme token audit runbook', url: 'https://example.com' },
{ id: 'c-2', index: 2, title: 'Release checklist, internal' },
]
const full = 'Behind a flag [1]. The audit walks every theme token, so a cold landing means a long tail of unreviewed diffs [2].'
const text = ref('')
const streaming = ref(true)
const replay = () => {
text.value = ''
streaming.value = true
const words = full.split(' ')
let index = 0
const tick = () => {
index += 1
text.value = words.slice(0, index).join(' ')
if (index < words.length) {
window.setTimeout(tick, 60)
}
}
tick()
}
onMounted(replay)
</script>
Customize
Props
| Prop | Type | Default | Description |
|---|---|---|---|
text | string | - | Required. The response so far; re-render it as it grows. It has no default on purpose — giving it one would also make it optional, which it is not. |
citations | AIResponseCitation[] | [] | Sources referenced by
|
isStreaming | boolean | false | Show a caret after the last word. The caret is aria-hidden. |
class | string | '' | Merged onto the paragraph, which also carries
text-pretty text-default text-sm leading-relaxed. |
UI
This component's theme exposes the slots below. Override any of them per instance with the ui prop, or app-wide under ui.aiResponse in app.config.ts.
| Prop | Type | Default | Description |
|---|---|---|---|
ui | object | - | Per-slot classes merged over the theme defaults. |
| Slot | Props | Description |
|---|---|---|
root | - | text-pretty text-default text-sm leading-relaxed |
citation | - | mr-0.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full border border-default bg-muted px-1 align-super font-medium text-[10px] text-muted no-underline |
word | - | inline-block |
caret | - | ml-0.5 inline-block h-[1em] w-[2px] translate-y-[0.15em] rounded-full bg-current align-baseline |
Accessibility
- A citation with a
urlrenders as a real anchor withtarget="_blank",rel="noopener noreferrer"and atitle, so it is reachable by keyboard, middle-clickable, and readable on hover. One without a URL renders as a<span>— never a link that goes nowhere. - The caret is
aria-hidden="true". Streaming is visual; announcing it would interrupt every reader on every token. text-prettyis used on the paragraph so the browser balances the last line rather than leaving a single word stranded.- Under
prefers-reduced-motion: reducethe words render without blur, offset or fade, the pills appear at full scale, and the caret stops pulsing and stays solid. The text is never partially hidden. - A
[n]marker with no matching citation stays as literal text. The component does not invent a placeholder pill for a reference it cannot resolve. - The component sets no ARIA role. Inside an
AI Message it inherits the turn's semantics;
standalone it is a
<p>, which is correct for a paragraph of prose.
Performance
- Transition objects are frozen and hoisted at module scope. Motion restarts an animation whenever its transition changes identity, and this component re-renders on every token — handing over a fresh object each time would restart every word on screen.
- The
tokenscomputed splits and resolves citations once per text change, not once per render. paintedCountis watched withflush: 'post', so the new-token test is against a count already committed to the DOM. Watching it earlier would mark the arriving word as already-painted and drop its animation.- Words animate
filter: blur()andopacity— no layout properties, no width measurement, and no reflow per token. - The pill list is a linear
findovercitationsper marker. That is fine for the handful of sources a single answer carries; a transcript with hundreds of citations per turn would want an index map. - Re-splitting the whole string on every token is O(n) per token. For long transcripts this is the component's real ceiling, and the answer is to render settled turns statically rather than streaming them again.