Skip to content

Plain JavaScript and Web Component ​

Two ways to use the package without React, Vue or Svelte: the imperative DOM handle, and a custom element that wraps the same handle.

createSmoothText ​

ts
import { createSmoothText } from 'smooth-stream-text/dom'

const element = document.querySelector('#answer')!
const answer = createSmoothText(element)

answer.onSettled(() => showActions())

try {
  for await (const delta of stream) answer.push(delta)
} finally {
  // Finished, stopped or failed: close the answer, and the paced tail drains.
  answer.end()
}

The handle owns the element's children from the moment it is created; give it an empty element. It paces what you push and fades new characters in. onSettled fires once per answer, when the last character has finished its fade-in.

The rest of the handle:

  • setText(fullText) instead of push, if your transport gives you the growing string.
  • flush() shows everything received so far. For a "Show all" button that should skip the delay for the rest of the answer, call it again after every later push.
  • reset(text?) starts over; with text, that text is already fully shown.
  • pause() and resume() for an element that is hidden or detached. Nothing is lost.
  • subscribe(listener) gives you the pacing state (text, phase, pending) on every change, for example to keep a data-phase attribute up to date.
  • destroy() when the element goes away for good. The text stays where it is.

Finished history needs no handle at all: put the text in the element as plain text. A handle leaves nothing but plain text behind once an answer is done.

Pacing your own markup ​

If you render the text yourself, for example Markdown into several elements, use the core for pacing and createReveal for the fade-in on each piece:

ts
import { createReveal, createSmoothStream, createWaveTimeline, resolveRevealOptions } from 'smooth-stream-text/dom'

const stream = createSmoothStream()
const timeline = createWaveTimeline(resolveRevealOptions())
const paragraph = createReveal(element, { timeline })

stream.subscribe((state) => paragraph.update(state.text))

update(fullVisibleText) takes the whole visible text of that piece and animates only what was appended. A shared timeline keeps the stagger continuous across pieces. The playground renders Markdown this way in about 200 lines of TypeScript.

The Web Component ​

html
<script type="module">
  import 'smooth-stream-text/web-component'
</script>

<smooth-stream-text text="A finished answer from history." done></smooth-stream-text>

Importing the module registers <smooth-stream-text>. With both text and done set before the element joins the page, the text appears at once: that is how history is rendered. A live answer is an empty element that you feed:

ts
const element = document.createElement('smooth-stream-text')
element.addEventListener('sst:done', () => announce('Answer complete'))
list.append(element)

for await (const delta of stream) element.push(delta)
element.end()

sst:done bubbles and fires once per answer, when it is fully on screen. Settings are attributes (target-latency, unit, duration, min-cps, max-cps and others; see the API reference) or, for anything structured, the options property. text and done are also properties, for frameworks that bind properties.

The stream belongs to the element, not to its place in the page. Moving it or taking it out and putting it back pauses and resumes it with nothing lost or replayed, and text pushed before the element is in the page is kept. An element removed for good needs no cleanup.

The element does not dispatch an event for each phase change. Read element.state when you need the current phase, or treat the answer as streaming from the first push until sst:done.

Importing the module on a server does not throw, so pages rendered on the server can import it directly. To use another tag name, call defineSmoothStreamTextElement('my-tag').

In frameworks without their own binding ​

Any framework that renders custom elements can use it: Angular (add CUSTOM_ELEMENTS_SCHEMA), Solid, Qwik, Lit, Astro and server-rendered HTML. Bind text and done as properties or attributes, and listen for sst:done.

Complete example ​

examples/vanilla puts both on one page, fed by the same simulated stream, with Stop, Show all and Clear chat. The imperative panel:

js
import { createSmoothText } from 'smooth-stream-text/dom'
import { history } from './answers.js'
import { createChatPanel } from './chat-panel.js'
import { simulateStream } from './simulated-stream.js'

/** @param {HTMLElement} section */
export function mountImperativePanel(section) {
  const panel = createChatPanel(section)
  const { buttons } = panel
  /** Every handle on screen, so that Clear chat can release them. */
  const handles = new Set()
  /** The newest answer: its handle, and whether Show all was pressed for it. */
  let current = null
  /** The open transport, while there is one. */
  let transport = null

  for (const turn of history) {
    panel.addMessage('user', turn.question)
    // Finished text needs no handle: plain text is all a handle leaves on
    // screen once an answer is done.
    const body = panel.addMessage('assistant', turn.answer)
    body.dataset.phase = 'done'
  }

  function updateButtons() {
    buttons.send.disabled = transport !== null || !panel.input.value.trim()
    buttons.stop.disabled = transport === null
    buttons.showAll.hidden = !current || current.showAll || current.handle.state.phase === 'done'
  }

  panel.form.addEventListener('submit', async (event) => {
    event.preventDefault()
    const question = panel.input.value.trim()
    if (!question || transport) return

    const answer = panel.nextAnswer()
    panel.addMessage('user', question)
    const body = panel.addMessage('assistant', '')
    // The handle owns `body` from here on: it paces what is pushed and fades it in.
    const handle = createSmoothText(body)
    const active = { handle, showAll: false }
    handles.add(handle)
    current = active

    body.dataset.phase = handle.state.phase
    handle.subscribe(({ phase }) => {
      if (body.dataset.phase === phase) return
      body.dataset.phase = phase
      updateButtons()
    })
    // Once per answer, when its last character has finished fading in.
    handle.onSettled(() => panel.announce('Answer complete'))
    panel.announce('')

    const controller = new AbortController()
    transport = controller
    updateButtons()
    try {
      for await (const chunk of simulateStream(answer, { signal: controller.signal })) {
        handle.push(chunk)
        // After Show all, the rest of this answer is shown as it lands.
        if (active.showAll) handle.flush()
      }
    } finally {
      // Finished, stopped or failed: close the answer, and the paced tail drains.
      handle.end()
      if (transport === controller) transport = null
      updateButtons()
    }
  })

  panel.input.addEventListener('input', updateButtons)

  buttons.stop.addEventListener('click', () => transport?.abort())

  buttons.showAll.addEventListener('click', () => {
    if (!current) return
    current.showAll = true
    current.handle.flush()
    updateButtons()
  })

  buttons.clear.addEventListener('click', () => {
    transport?.abort()
    // The elements are going away, so their handles go too: destroy() stops
    // every frame and timer they had scheduled.
    for (const handle of handles) handle.destroy()
    handles.clear()
    current = null
    panel.list.replaceChildren()
    panel.announce('')
    updateButtons()
  })

  updateButtons()
}

The Web Component panel:

js
// Registers <smooth-stream-text>.
import 'smooth-stream-text/web-component'
import { history } from './answers.js'
import { createChatPanel } from './chat-panel.js'
import { simulateStream } from './simulated-stream.js'

/** @param {HTMLElement} section */
export function mountWebComponentPanel(section) {
  const panel = createChatPanel(section)
  const { buttons } = panel
  /** The newest answer: its element, and whether Show all was pressed for it. */
  let current = null
  /** The open transport, while there is one. */
  let transport = null

  for (const turn of history) {
    panel.addMessage('user', turn.question)
    // The same as <smooth-stream-text text="..." done> in HTML. Both are set
    // before the element joins the page, so it shows the text at once.
    const element = document.createElement('smooth-stream-text')
    element.setAttribute('text', turn.answer)
    element.setAttribute('done', '')
    element.dataset.phase = 'done'
    panel.addMessage('assistant', element)
  }

  function updateButtons() {
    buttons.send.disabled = transport !== null || !panel.input.value.trim()
    buttons.stop.disabled = transport === null
    buttons.showAll.hidden = !current || current.showAll || current.element.dataset.phase === 'done'
  }

  panel.form.addEventListener('submit', async (event) => {
    event.preventDefault()
    const question = panel.input.value.trim()
    if (!question || transport) return

    const answer = panel.nextAnswer()
    panel.addMessage('user', question)
    const element = document.createElement('smooth-stream-text')
    const active = { element, showAll: false }
    current = active

    // The element reports no phase changes, only `sst:done`, once per answer
    // when its last character has finished fading in.
    element.dataset.phase = 'streaming'
    element.addEventListener('sst:done', () => {
      element.dataset.phase = 'done'
      panel.announce('Answer complete')
      updateButtons()
    })
    panel.addMessage('assistant', element)
    panel.announce('')

    const controller = new AbortController()
    transport = controller
    updateButtons()
    try {
      for await (const chunk of simulateStream(answer, { signal: controller.signal })) {
        element.push(chunk)
        // After Show all, the rest of this answer is shown as it lands.
        if (active.showAll) element.flush()
      }
    } finally {
      // Finished, stopped or failed: close the answer, and the paced tail drains.
      element.end()
      if (transport === controller) transport = null
      updateButtons()
    }
  })

  panel.input.addEventListener('input', updateButtons)

  buttons.stop.addEventListener('click', () => transport?.abort())

  buttons.showAll.addEventListener('click', () => {
    if (!current) return
    current.showAll = true
    current.element.flush()
    updateButtons()
  })

  buttons.clear.addEventListener('click', () => {
    transport?.abort()
    // Nothing to release by hand: an element taken out of the page pauses
    // itself, and one that never comes back is simply garbage collected.
    current = null
    panel.list.replaceChildren()
    panel.announce('')
    updateButtons()
  })

  updateButtons()
}

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