Skip to content

Vue and Nuxt ​

ts
import { useSmoothStream, SmoothText, Reveal, RevealProvider } from 'smooth-stream-text/vue'

Vue 3.2 or newer. Verified with Vue 3.5 and Nuxt 4.6.

useSmoothStream: the headless path ​

vue
<script setup lang="ts">
import { computed } from 'vue'
import { useSmoothStream } from 'smooth-stream-text/vue'

const props = defineProps<{ text: string; streaming: boolean }>()

const { text, phase, flush } = useSmoothStream(() => props.text, { done: () => !props.streaming })
const html = computed(() => renderMarkdown(text.value)) // your renderer
</script>

<template>
  <div :data-phase="phase" v-html="html" />
</template>

The source can be a ref or a getter; so can done, which also accepts a plain boolean. A getter is the natural way to pass props. text and phase are computed refs, and state is a ref of the full state (pending, charsPerSecond and the rest). Pacing options go in the same object as done and can be changed later with setOptions.

If your renderer accepts raw HTML from the model, sanitize it there, exactly as without pacing; the example uses marked with isomorphic-dompurify.

The same rules hold as everywhere: a message that is already done when it first arrives is history and appears at once; when done turns true, the rest drains within about a second; text that does not continue the previous text starts a new answer.

Show all ​

flush() shows everything received so far. Text that arrives later would be paced again, so to skip the delay for the rest of the answer, keep flushing while text is pending:

ts
watchEffect(() => {
  if (props.showAll && state.value.pending > 0) flush()
})

Knowing when an answer is on screen ​

Watch phase. History starts out done and never changes, so a watcher on the change to done fires only for answers that were actually paced:

ts
watch(phase, (next) => {
  if (next === 'done') emit('done')
})

SmoothText: pacing and reveal in one element ​

For plain text without your own renderer:

vue
<SmoothText :text="answer" :done="!streaming" :options="{ targetLatencyMs: 150 }" @settled="showActions" />

settled is emitted once per answer, when its last character has finished fading in. as sets the host element (div by default) and className its class.

For Markdown you render yourself, RevealProvider and Reveal add the fade-in to your own elements; see the React guide for how the reveal behaves, which is the same in Vue.

Lifecycle ​

Pacing starts once the component is mounted, so server rendering schedules nothing. Inside <KeepAlive>, the stream pauses while the component is deactivated and continues when it is shown again. The stream is destroyed with the component's scope.

Nuxt ​

Load the conversation so that it is part of the server render, for example with useFetch, and pass finished messages with streaming: false. Their text is in the server HTML, complete, and hydration does not replay it. Only the answer that is still streaming is paced, on the client.

smooth-stream-text/vue can be imported in components directly; no Nuxt module is needed.

Complete example ​

examples/nuxt is a Nuxt 4 app with a server route streaming Server-Sent Events, history loaded with useFetch, Stop and Show all. The assistant message:

vue
<script setup lang="ts">
import { useSmoothStream } from 'smooth-stream-text/vue'
import { renderMarkdown } from '~/utils/markdown'

const props = defineProps<{
  text: string
  streaming: boolean
  /** Reveal the rest of this answer without pacing. */
  showAll?: boolean
}>()

const emit = defineEmits<{
  /** The answer is completely on screen. Never emitted for history, which starts that way. */
  done: []
}>()

// `text` is an evenly growing prefix of `props.text`. A message that is already
// finished on its first render, like history, is shown at once, also on the server.
const { text, phase, state, flush } = useSmoothStream(() => props.text, { done: () => !props.streaming })
const html = computed(() => renderMarkdown(text.value))

// flush() reveals what has arrived so far. While the answer is still arriving,
// keep flushing, so that "Show all" also covers the text that comes after it.
watchEffect(() => {
  if (props.showAll && state.value.pending > 0) flush()
})

watch(phase, (next) => {
  if (next === 'done') emit('done')
})
</script>

<template>
  <div class="answer" data-testid="assistant-message" :data-phase="phase" v-html="html" />
</template>

MIT licensed. Plain-text docs for tools and assistants: llms.txt, llms-full.txt, or append .md to any page URL.