Skip to content

Streaming Text ​

Streamed AI answers where each new chunk blurs in, with a blinking cursor and light Markdown.

How should I debounce a search input in Vue 3?

Installation ​

pnpm dlx shadcn-vue@latest add https://vuelix.dev/r/streaming-text.json
Manual installation

Install the dependencies:

bash
npm install motion-v

Then copy these files into components/ui/streaming-text/ (plus lib/utils.ts if you don't have it):

vue
<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>
ts
// 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 }] }] }]
    : []
}
ts
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 }
}
ts
export { default as StreamingText } from './StreamingText.vue'
export { createTextStream, useTextStream } from './use-text-stream'
export type { TextStreamOptions } from './use-text-stream'

Usage ​

ts
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:

vue
<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:

vue
<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.

ts
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 ​

PropTypeDefaultDescription
textstring''The text received so far. When it extends the previous value, only the new part animates; any other change starts over.
streamAsyncIterable<string>—Chunks to consume and accumulate instead of text.
streamingbooleanfalseShows the blinking cursor. Always on while a stream is being read.
markdownbooleanfalseRenders 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.
hideCursorbooleanfalseNever show the cursor.
asstring'div'Root element.
EventPayloadDescription
donetext: stringA stream was fully consumed.
useTextStream(source, options)Description
sourceMaybeRefOrGetter<string>: the full text to replay.
options.intervalAverage ms between chunks (default 32, jittered ±50%).
options.delayMs before the first chunk (default 0).
options.autoStartStart 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.

Designed by Kuldeep Pisda · Released under the MIT License