Streaming Text
Streamed AI answers where each new chunk blurs in, with a blinking cursor and light Markdown.
Installation
pnpm dlx shadcn-vue@latest add https://vuelix.dev/r/streaming-text.jsonManual installation
Install the dependencies:
npm install motion-vThen copy these files into components/ui/streaming-text/ (plus lib/utils.ts if you don't have it):
<script setup lang="ts">
import type { Directive, HTMLAttributes } from 'vue'
import type { StreamBlock, StreamInline } from './markdown'
import { animate, motion, useReducedMotion } from 'motion-v'
import { computed, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
import { cn } from '@/lib/utils'
import { parseStreamMarkdown, plainBlock } from './markdown'
const props = withDefaults(defineProps<{
/** The text received so far. Grow it over time and only the new part animates in. */
text?: string
/** Alternatively, an async iterable of chunks; the component consumes and accumulates it. */
stream?: AsyncIterable<string>
/** Show the blinking cursor (automatically on while a `stream` is being read). */
streaming?: boolean
/** Render a small Markdown subset: paragraphs, `#` headings, lists, **bold** and `code`. */
markdown?: boolean
/** How each new chunk enters. */
animation?: 'blur' | 'fade' | 'none'
/** Hide the cursor even while streaming. */
hideCursor?: boolean
/** Root element. */
as?: string
class?: HTMLAttributes['class']
}>(), {
text: '',
streaming: false,
markdown: false,
animation: 'blur',
hideCursor: false,
as: 'div',
})
const emit = defineEmits<{
/** Fired when a `stream` has been fully consumed, with the final text. */
done: [text: string]
}>()
const reduced = useReducedMotion()
// --- source: the `text` prop, or the accumulated `stream` ------------------
const buffer = ref('')
const reading = ref(false)
let readToken = 0
async function consume(stream: AsyncIterable<string> | undefined) {
const token = ++readToken
if (!stream) {
reading.value = false
return
}
buffer.value = ''
reading.value = true
try {
for await (const chunk of stream) {
if (token !== readToken)
return
buffer.value += chunk
}
}
finally {
if (token === readToken) {
reading.value = false
emit('done', buffer.value)
}
}
}
onMounted(() => consume(props.stream))
watch(() => props.stream, consume)
onBeforeUnmount(() => readToken++)
const source = computed(() => (props.stream ? buffer.value : props.text))
const isStreaming = computed(() => props.streaming || reading.value)
// --- chunk bookkeeping -------------------------------------------------------
// `boundaries` holds the source offset where every chunk began, so each chunk renders as
// its own span. Anything starting at or after `newFrom` arrived in the latest update and
// fades in; older spans that re-mount (e.g. when `**` closes) appear without animation.
const boundaries = shallowRef<number[]>([])
const newFrom = ref(props.streaming || props.stream ? 0 : props.text.length)
watch(source, (next, prev) => {
if (next.startsWith(prev)) {
if (next.length > prev.length) {
boundaries.value = [...boundaries.value, prev.length]
newFrom.value = prev.length
}
}
else {
// Replaced rather than extended: a new answer.
boundaries.value = [0]
newFrom.value = 0
}
})
const blocks = computed<StreamBlock[]>(() =>
props.markdown ? parseStreamMarkdown(source.value) : plainBlock(source.value),
)
function pieces(leaf: StreamInline) {
const end = leaf.start + leaf.text.length
const cuts = boundaries.value.filter(b => b > leaf.start && b < end)
const out: { start: number, text: string }[] = []
let from = leaf.start
for (const cut of [...cuts, end]) {
out.push({ start: from, text: leaf.text.slice(from - leaf.start, cut - leaf.start) })
from = cut
}
return out
}
const enter = computed(() => ({
blur: { opacity: [0, 1], filter: ['blur(6px)', 'blur(0px)'] },
fade: { opacity: [0, 1] },
none: null,
})[props.animation])
/** Fades a freshly mounted chunk in. Plain element animation — no component per chunk. */
const vReveal: Directive<HTMLElement, boolean> = {
beforeMount(el, { value }) {
if (value && enter.value && !reduced.value)
el.style.opacity = '0'
},
mounted(el, { value }) {
if (value && enter.value && !reduced.value)
animate(el, enter.value, { duration: 0.45, ease: 'easeOut' })
},
}
const blockTag = (b: StreamBlock) => (b.type === 'h' ? `h${Math.min((b.level ?? 1) + 2, 6)}` : b.type === 'plain' ? 'p' : b.type)
const rowTag = (b: StreamBlock) => (b.type === 'ul' || b.type === 'ol' ? 'li' : 'span')
const blockClass = (b: StreamBlock) => ({
p: 'm-0',
plain: 'm-0 whitespace-pre-wrap',
h: cn('m-0 font-semibold tracking-tight text-foreground', b.level === 1 ? 'text-lg' : 'text-base'),
ul: 'm-0 list-disc space-y-1 pl-5 marker:text-muted-foreground',
ol: 'm-0 list-decimal space-y-1 pl-5 marker:text-muted-foreground',
})[b.type]
const leafTag = (l: StreamInline) => (l.kind === 'strong' ? 'strong' : l.kind === 'code' ? 'code' : 'span')
const leafClass = (l: StreamInline) =>
l.kind === 'strong'
? 'font-semibold text-foreground'
: l.kind === 'code'
? 'rounded-md border bg-muted box-decoration-clone px-1.5 py-px font-mono text-[0.85em] text-foreground'
: undefined
const showCursor = computed(() => isStreaming.value && !props.hideCursor)
const isLast = (bi: number, ri: number) => bi === blocks.value.length - 1 && ri === blocks.value[bi]!.rows.length - 1
const blink = { duration: 1.05, repeat: Infinity, times: [0, 0.5, 0.5, 1], ease: 'linear' } as const
</script>
<template>
<component
:is="props.as"
:class="cn('flex flex-col gap-3 text-sm leading-7 text-foreground/90', props.class)"
aria-live="polite"
:aria-busy="isStreaming"
>
<component
:is="blockTag(block)"
v-for="(block, bi) in blocks"
:key="block.key"
:class="blockClass(block)"
:start="block.type === 'ol' && block.startNumber !== 1 ? block.startNumber : undefined"
>
<component :is="rowTag(block)" v-for="(row, ri) in block.rows" :key="row.key">
<component :is="leafTag(leaf)" v-for="leaf in row.inlines" :key="`${leaf.kind}${leaf.start}`" :class="leafClass(leaf)">
<span v-for="piece in pieces(leaf)" :key="piece.start" v-reveal="piece.start >= newFrom">{{ piece.text }}</span>
</component>
<motion.span
v-if="showCursor && isLast(bi, ri)"
aria-hidden="true"
class="ml-0.5 inline-block h-[1.05em] w-[0.5em] translate-y-[0.18em] rounded-[3px] bg-foreground/80"
:animate="reduced ? { opacity: 1 } : { opacity: [1, 1, 0, 0] }"
:transition="reduced ? { duration: 0 } : blink"
/>
</component>
</component>
<p v-if="showCursor && !blocks.length" class="m-0">
<motion.span
aria-hidden="true"
class="inline-block h-[1.05em] w-[0.5em] translate-y-[0.18em] rounded-[3px] bg-foreground/80"
:animate="reduced ? { opacity: 1 } : { opacity: [1, 1, 0, 0] }"
:transition="reduced ? { duration: 0 } : blink"
/>
</p>
</component>
</template>// A deliberately tiny Markdown subset for streamed AI answers: paragraphs, `#` headings,
// `-` / `1.` lists, **bold** and `code`. Every inline remembers its offset in the source
// string so the renderer can tell which characters arrived in which chunk.
//
// Unclosed markers (`**bol` mid-stream) are rendered optimistically as if they were closed,
// so text doesn't flash from plain to bold when the closing marker arrives.
export interface StreamInline {
kind: 'text' | 'strong' | 'code'
/** Offset of the first visible character in the source string. */
start: number
text: string
}
export interface StreamRow {
key: string
inlines: StreamInline[]
}
export interface StreamBlock {
key: string
type: 'p' | 'h' | 'ul' | 'ol' | 'plain'
level?: number
/** First number of an ordered list. */
startNumber?: number
/** One row for paragraphs / headings, one per item for lists. */
rows: StreamRow[]
}
function parseInline(line: string, offset: number): StreamInline[] {
const out: StreamInline[] = []
let buf = ''
let bufStart = offset
const flush = () => {
if (buf)
out.push({ kind: 'text', start: bufStart, text: buf })
buf = ''
}
let i = 0
while (i < line.length) {
const marker = line.startsWith('**', i) ? '**' : line[i] === '`' ? '`' : ''
if (!marker) {
if (!buf)
bufStart = offset + i
buf += line[i]
i++
continue
}
flush()
const open = i + marker.length
const close = line.indexOf(marker, open)
const end = close === -1 ? line.length : close
const text = line.slice(open, end)
if (text)
out.push({ kind: marker === '`' ? 'code' : 'strong', start: offset + open, text })
i = close === -1 ? line.length : close + marker.length
}
flush()
return out
}
const HEADING = /^(#{1,3})\s+(.*)$/
const BULLET = /^\s*[-*+]\s+(.*)$/
const NUMBERED = /^\s*(\d+)[.)]\s+(.*)$/
export function parseStreamMarkdown(source: string): StreamBlock[] {
const blocks: StreamBlock[] = []
let current = null as StreamBlock | null
let offset = 0
for (const line of source.split('\n')) {
const lineStart = offset
offset += line.length + 1
if (!line.trim()) {
current = null
continue
}
const heading = HEADING.exec(line)
if (heading) {
const contentStart = lineStart + line.length - heading[2]!.length
blocks.push({
key: `h${lineStart}`,
type: 'h',
level: heading[1]!.length,
rows: [{ key: `r${lineStart}`, inlines: parseInline(heading[2]!, contentStart) }],
})
current = null
continue
}
const bullet = BULLET.exec(line)
const numbered = bullet ? null : NUMBERED.exec(line)
if (bullet || numbered) {
const type = bullet ? 'ul' : 'ol'
const content = (bullet ? bullet[1] : numbered![2])!
const contentStart = lineStart + line.length - content.length
if (current?.type !== type) {
current = { key: `${type}${lineStart}`, type, rows: [], startNumber: numbered ? Number(numbered[1]) : undefined }
blocks.push(current)
}
current.rows.push({ key: `r${lineStart}`, inlines: parseInline(content, contentStart) })
continue
}
const indented = /^\s/.test(line)
const open = current as StreamBlock | null
if (open && (open.type === 'p' || ((open.type === 'ul' || open.type === 'ol') && indented))) {
// Continuation line: join with a space, like Markdown does.
const row = open.rows.at(-1)!
row.inlines.push({ kind: 'text', start: lineStart - 1, text: ' ' })
const trimmed = line.trimStart()
row.inlines.push(...parseInline(trimmed, lineStart + line.length - trimmed.length))
continue
}
current = { key: `p${lineStart}`, type: 'p', rows: [{ key: `r${lineStart}`, inlines: parseInline(line, lineStart) }] }
blocks.push(current)
}
return blocks
}
export function plainBlock(source: string): StreamBlock[] {
return source
? [{ key: 'plain', type: 'plain', rows: [{ key: 'r0', inlines: [{ kind: 'text', start: 0, text: source }] }] }]
: []
}import type { MaybeRefOrGetter, Ref } from 'vue'
import { getCurrentInstance, getCurrentScope, onMounted, onScopeDispose, ref, toValue } from 'vue'
export interface TextStreamOptions {
/** Average milliseconds between chunks (each gap is jittered ±50%). */
interval?: number
/** Milliseconds to wait before the first chunk — the "time to first token". */
delay?: number
/** Start streaming as soon as the calling component mounts. */
autoStart?: boolean
}
/**
* Cuts text into chunks that look like LLM tokens: one to three words at a time,
* with long words split into 3–6 character pieces.
*/
export function chunkText(text: string): string[] {
const parts = text.match(/\s+|[^\s]+/g) ?? []
const tokens: string[] = []
for (const part of parts) {
if (part.length <= 7 || /^\s+$/.test(part)) {
tokens.push(part)
continue
}
for (let i = 0; i < part.length;) {
const size = 3 + Math.floor(Math.random() * 4)
tokens.push(part.slice(i, i + size))
i += size
}
}
const chunks: string[] = []
for (let i = 0; i < tokens.length;) {
const take = 1 + Math.floor(Math.random() * 3)
chunks.push(tokens.slice(i, i + take).join(''))
i += take
}
return chunks
}
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
const jitter = (ms: number) => ms * (0.5 + Math.random())
/** An async iterable that yields `text` in token-like chunks — handy for the `stream` prop and tests. */
export async function* createTextStream(text: string, options: Pick<TextStreamOptions, 'interval' | 'delay'> = {}) {
const { interval = 32, delay = 0 } = options
if (delay)
await sleep(delay)
for (const chunk of chunkText(text)) {
yield chunk
await sleep(jitter(interval))
}
}
/**
* Simulates a streamed response: `text` grows chunk by chunk after `start()`.
*
* ```ts
* const { text, streaming, start } = useTextStream(answer, { autoStart: true })
* ```
*/
export function useTextStream(source: MaybeRefOrGetter<string>, options: TextStreamOptions = {}): {
text: Ref<string>
streaming: Ref<boolean>
start: () => void
stop: () => void
complete: () => void
} {
const { interval = 32, delay = 0, autoStart = false } = options
const text = ref('')
const streaming = ref(false)
let timer: ReturnType<typeof setTimeout> | undefined
function stop() {
clearTimeout(timer)
streaming.value = false
}
function start() {
stop()
text.value = ''
streaming.value = true
const chunks = chunkText(toValue(source))
let i = 0
const tick = () => {
if (i >= chunks.length) {
streaming.value = false
return
}
text.value += chunks[i++]
timer = setTimeout(tick, jitter(interval))
}
timer = setTimeout(tick, delay)
}
/** Jump straight to the full text. */
function complete() {
stop()
text.value = toValue(source)
}
if (getCurrentScope())
onScopeDispose(stop)
if (autoStart && getCurrentInstance())
onMounted(start)
return { text, streaming, start, stop, complete }
}export { default as StreamingText } from './StreamingText.vue'
export { createTextStream, useTextStream } from './use-text-stream'
export type { TextStreamOptions } from './use-text-stream'Usage
import { StreamingText } from '@/components/ui/streaming-text'Grow text as tokens arrive. Only the part that is new since the last update animates, so you can append on every chunk:
<StreamingText :text="answer" :streaming="isGenerating" markdown />Or hand it an async iterable (for example a ReadableStream of decoded text) and let it accumulate the chunks itself:
<StreamingText :stream="response" markdown @done="save" />Simulating a stream
useTextStream() replays a string in token-sized chunks, for demos, loading states and tests. createTextStream() does the same as an async iterable for the stream prop.
import { createTextStream, useTextStream } from '@/components/ui/streaming-text'
const { text, streaming, start, stop, complete } = useTextStream(answer, { interval: 40, autoStart: true })
const stream = createTextStream(answer, { delay: 300 })API
| Prop | Type | Default | Description |
|---|---|---|---|
text | string | '' | The text received so far. When it extends the previous value, only the new part animates; any other change starts over. |
stream | AsyncIterable<string> | — | Chunks to consume and accumulate instead of text. |
streaming | boolean | false | Shows the blinking cursor. Always on while a stream is being read. |
markdown | boolean | false | Renders paragraphs, # headings, - / 1. lists, **bold** and `code`. Unclosed markers mid-stream render as if closed. |
animation | 'blur' | 'fade' | 'none' | 'blur' | How each new chunk enters. |
hideCursor | boolean | false | Never show the cursor. |
as | string | 'div' | Root element. |
| Event | Payload | Description |
|---|---|---|
done | text: string | A stream was fully consumed. |
useTextStream(source, options) | Description |
|---|---|
source | MaybeRefOrGetter<string>: the full text to replay. |
options.interval | Average ms between chunks (default 32, jittered ±50%). |
options.delay | Ms before the first chunk (default 0). |
options.autoStart | Start when the calling component mounts (default false). |
| returns | { text, streaming, start, stop, complete }. |
The root is an aria-live="polite" region with aria-busy set while streaming, so screen readers announce the answer once rather than on every token.