# smooth-stream-text 0.2.2 documentation
> Adaptive text-stream smoothing for AI chat interfaces. Keep your Markdown renderer and pace incoming text with a zero-dependency headless core.
Source: https://smooth-stream-text.vercel.app/docs/. Each section below is also available on its own at the URL given in it.
---
# smooth-stream-text
> smooth-stream-text paces streamed LLM text on the client so it appears at an even rate, while you keep your own Markdown renderer. When it fits, when it does not, and which integration to use.
>
> smooth-stream-text 0.2.2. HTML version: https://smooth-stream-text.vercel.app/docs/
A language model's answer reaches the browser unevenly: a word, then forty at once,
then nothing while the server thinks. If every chunk is rendered the moment it
lands, the text jumps, and the jumps are what the reader notices.
`smooth-stream-text` sits between your transport and your renderer. You hand it the
text received so far and whether the answer is finished. It hands back a prefix of
that text that grows at an even, adaptive rate. You render that prefix exactly as
you render text today, with react-markdown, Streamdown, marked or your own parser.
```sh
npm install smooth-stream-text
```
```tsx
import { useSmoothText } from 'smooth-stream-text/react'
function Answer({ answer, streaming }: { answer: string; streaming: boolean }) {
const { text } = useSmoothText(answer, { done: !streaming })
return {text}
}
```
The core has no runtime dependencies and runs without a DOM. React, Vue and Svelte
bindings and a Web Component are included; each framework is an optional peer
dependency, so you install only the one you use. A fade-in reveal for new text is
available as a separate layer and is never required.
The [playground](https://smooth-stream-text.vercel.app) streams the same answer twice,
once as it arrives and once paced, through the same renderer.
## Three separate jobs
A streaming chat view does three different things, and it helps to keep them apart:
1. **Transport** receives text: SSE, a fetch stream, a WebSocket, the AI SDK. This
package does none of it.
2. **Pacing** decides how much of the received text is visible right now. This is
what the package does. The rate follows the backlog: it holds the display a
fixed distance behind the live text (180 ms by default), so a fast model is shown
faster and a slow one slower, without a characters-per-second setting to tune.
3. **Rendering** turns visible text into markup. That stays with your renderer. The
package only makes sure the visible prefix never ends inside a `**`, a
half-written link or an emoji, so a Markdown renderer never flashes broken syntax.
The optional reveal animates the newly visible part. If your renderer already
animates new words, as Streamdown can, use only the pacing.
## When it fits
- Text arrives in uneven bursts and you want it to read as a steady flow.
- You already have a renderer and do not want to replace it.
- One answer can be long, multilingual or full of Markdown, and cutting it in the
wrong place would show broken characters or syntax.
- You need history to appear at once and only new answers to be paced, across
React StrictMode, server rendering and remounts.
## When it does not
- You only need to render Markdown, with no streaming. Use a Markdown renderer.
- You need retries, reconnects or parsing of a network protocol. That is transport.
- The text is static and you want a typing effect for a heading. CSS is enough.
- Your project already paces text in one layer, for example with the AI SDK's
`smoothStream()` on the server, and you are happy with it. Adding a second layer
adds a second delay; see [latency](https://smooth-stream-text.vercel.app/docs/concepts/latency.md).
- You target React Native or a terminal. The core runs anywhere JavaScript runs, but
only the web bindings are built and tested.
## Pick your integration
| You use | Start with |
| --- | --- |
| React with react-markdown or your own renderer | [React](https://smooth-stream-text.vercel.app/docs/react.md) |
| The Vercel AI SDK's `useChat` | [AI SDK](https://smooth-stream-text.vercel.app/docs/integrations/ai-sdk.md) |
| Next.js App Router | [Next.js](https://smooth-stream-text.vercel.app/docs/integrations/nextjs.md) |
| Streamdown | [Streamdown](https://smooth-stream-text.vercel.app/docs/integrations/streamdown.md) |
| Vue 3 or Nuxt | [Vue and Nuxt](https://smooth-stream-text.vercel.app/docs/vue.md) |
| Svelte or SvelteKit | [Svelte and SvelteKit](https://smooth-stream-text.vercel.app/docs/svelte.md) |
| Plain JavaScript, Angular, Solid, Astro | [Plain JavaScript and Web Component](https://smooth-stream-text.vercel.app/docs/dom.md) |
| Something else, or a server | the [core API](https://smooth-stream-text.vercel.app/docs/api.md#core) |
Every guide links to a complete example app in the
[repository](https://github.com/crmapache/smooth-stream-text/tree/main/examples). The
examples install the package from npm and are built and tested in CI on every change.
## For tools and assistants
Every page of these docs is also available as plain Markdown: add `.md` to its URL,
for example [`/docs/react.md`](https://smooth-stream-text.vercel.app/docs/react.md). [`/llms.txt`](https://smooth-stream-text.vercel.app/llms.txt)
lists them, and [`/llms-full.txt`](https://smooth-stream-text.vercel.app/llms-full.txt)
contains them all in one file. The npm package ships a short
[`INTEGRATION.md`](https://github.com/crmapache/smooth-stream-text/blob/main/INTEGRATION.md)
that can be read offline from `node_modules/smooth-stream-text/`.
If you use a coding agent that supports [Agent Skills](https://agentskills.io) (Claude
Code, Codex, Gemini CLI and others), you can install a skill that walks it through an
integration: when the package fits, which recipe matches your stack, and what to check
afterwards.
```sh
npx skills add crmapache/smooth-stream-text
```
It is optional and installs nothing into your project's dependencies. The skill's source
is in [`skills/smooth-stream-text`](https://github.com/crmapache/smooth-stream-text/tree/main/skills/smooth-stream-text).
---
# Getting started
> Install smooth-stream-text, feed it the text your stream has received, and render the paced text it returns. React, Vue, Svelte, plain JavaScript and the headless core.
>
> smooth-stream-text 0.2.2. HTML version: https://smooth-stream-text.vercel.app/docs/getting-started
## Install
Use the package manager your project already uses:
```sh
npm install smooth-stream-text
# pnpm add smooth-stream-text
# yarn add smooth-stream-text
```
The package has no runtime dependencies. React (18 or newer) and Vue (3.2 or newer)
are optional peer dependencies: nothing extra is installed, and you only need the
framework you import from. It ships ES modules, CommonJS and TypeScript types for
every entry point.
## The one idea
Every integration works the same way. On each change you hand over two things:
- the **whole text received so far** for the current answer, and
- whether that answer is **done**, meaning the transport has closed it.
You get back the **visible text**: a prefix of what you handed over that grows at an
even rate, and a `phase` of `'idle'`, `'streaming'`, `'settling'` or `'done'`.
Three rules follow from that, and they hold in every binding:
1. Text that is already done the first time you hand it over is **history**. It
appears at once, without pacing or animation, so a loaded conversation does not
replay.
2. Text that does not continue the previous text is a **different answer**. If it is
already done, it is shown at once; if it is still streaming (a regenerated
answer, say), it is paced from the start.
3. `done` closes whichever answer is current. The rest of the backlog then drains to
the end within about a second (twice `settleMs`, 400 ms by default), even if the
last chunk arrives after `done` was set.
## React
```tsx
import { useSmoothText } from 'smooth-stream-text/react'
function Answer({ answer, streaming }: { answer: string; streaming: boolean }) {
const { text, phase } = useSmoothText(answer, { done: !streaming })
return {text}
}
```
`useSmoothText` re-renders only when the visible text or the phase changes: at
most once per animation frame while text is appearing. The entry point is marked
`'use client'`, so it can be imported from Next.js App Router client components.
More in the [React guide](https://smooth-stream-text.vercel.app/docs/react.md).
## Vue
```vue
```
More in the [Vue and Nuxt guide](https://smooth-stream-text.vercel.app/docs/vue.md).
## Svelte
```svelte
{$smooth.text}
```
More in the [Svelte and SvelteKit guide](https://smooth-stream-text.vercel.app/docs/svelte.md).
## Plain JavaScript
When the text is plain, the DOM handle does pacing and the fade-in reveal in one
element:
```ts
import { createSmoothText } from 'smooth-stream-text/dom'
const answer = createSmoothText(document.querySelector('#answer')!)
for await (const delta of stream) answer.push(delta)
answer.end()
```
The [plain JavaScript guide](https://smooth-stream-text.vercel.app/docs/dom.md) also covers the `` Web
Component, which works in Angular, Solid, Astro and any other framework that renders
custom elements.
## Without a DOM
The core is plain logic. It runs in Node 18 and newer, in workers and in tests:
```ts
import { createSmoothStream } from 'smooth-stream-text'
const stream = createSmoothStream()
stream.subscribe((state) => render(state.text))
for await (const delta of response) stream.push(delta)
stream.end()
```
`push` takes deltas and `setSource` takes the growing full string; use whichever your
transport gives you. See the [API reference](https://smooth-stream-text.vercel.app/docs/api.md#core).
## Settings that matter first
Defaults are chosen so that most chats need none. The ones worth knowing:
| Option | Default | What it changes |
| --- | --- | --- |
| `targetLatencyMs` | `180` | How far behind the live text the display runs. Lower feels more immediate, higher is smoother. |
| `unit` | `'char'` | `'word'` adds only whole words, in every script. |
| `maxCharsPerSecond` | `900` | Ceiling on the rate, so a burst is never dumped at once. |
| `settleMs` | `400` | How long the tail takes to drain once the answer is done. |
All of them, and the reveal settings, are in the [API reference](https://smooth-stream-text.vercel.app/docs/api.md#pacing-options).
## Next
- Wire it into your stack: [AI SDK](https://smooth-stream-text.vercel.app/docs/integrations/ai-sdk.md), [Next.js](https://smooth-stream-text.vercel.app/docs/integrations/nextjs.md),
[Streamdown](https://smooth-stream-text.vercel.app/docs/integrations/streamdown.md).
- Understand what pacing costs in time: [latency and completion](https://smooth-stream-text.vercel.app/docs/concepts/latency.md).
- See where the visible text may end: [text boundaries](https://smooth-stream-text.vercel.app/docs/concepts/text-boundaries.md).
---
# React
> Pace streamed LLM text in React with useSmoothText and keep react-markdown or any other renderer. Hooks, the SmoothText component, the optional reveal, history, Show all and StrictMode.
>
> smooth-stream-text 0.2.2. HTML version: https://smooth-stream-text.vercel.app/docs/react
```ts
import { useSmoothText, useSmoothStream, useSmoothStreamController, SmoothText, Reveal, RevealProvider } from 'smooth-stream-text/react'
```
React 18 and 19. The entry point starts with `'use client'`, so client components
in the Next.js App Router can import it directly. Server components cannot use the
hooks, as with any hook.
## useSmoothText: the usual path
Most chat UIs already parse Markdown and render it with their own components. Those
only need the paced string:
```tsx
import ReactMarkdown from 'react-markdown'
import { useSmoothText } from 'smooth-stream-text/react'
function Answer({ answer, streaming }: { answer: string; streaming: boolean }) {
const { text, phase, flush } = useSmoothText(answer, { done: !streaming })
return {text}
}
```
`answer` is the text received so far. `streaming` is your own flag for "the
transport may still add to this answer". `text` is a prefix of `answer` that grows
at an even rate. Parse and render it exactly as before.
The hook re-renders only when `text` or `phase` changes. While an answer appears,
that is at most once per animation frame, which can be more often than chunks arrive:
in the [browser benchmark](https://smooth-stream-text.vercel.app/docs/benchmarks.md#rendering-cost-in-a-browser) a recorded answer
was rendered about twice as often when paced as when rendered once per chunk. If your Markdown rendering is
expensive, `unit: 'word'` changes the visible text only at word boundaries and renders
less often, and memoizing finished blocks helps as it would without pacing.
Pacing options go in the same object: `useSmoothText(answer, { done, targetLatencyMs: 120 })`.
They can change between renders.
It returns `text`, `phase` and three controls: `flush()` shows everything received
so far, `reset(text?)` starts over (with `text` already fully shown), and
`setOptions(partial)` changes settings at runtime.
### History, new answers and one hook per message
The usual layout is one `Answer` component per assistant message, each with its
own hook. Pass `done: true` for every message except the one that is still arriving:
```tsx
messages.map((message, index) => (
))
```
A message that is already done on its first render is history: it appears at once
and never animates, so loading a conversation is instant. When the transport closes
the newest answer, `done` turns true and the remaining backlog drains within about a
second. `done` may flip before the last chunk lands; the late chunk is still shown.
One hook can also serve a whole conversation: text that does not continue the
previous text is treated as another answer. Separate hooks per message are simpler
to reason about and are what the examples use.
### Show all
`flush()` shows everything received so far. Text that arrives afterwards is paced
again, because the stream is still open. A "Show all" button usually means "no more
delay for this answer", so keep flushing until it ends:
```tsx
const [skipped, setSkipped] = useState(false)
const { text, phase, flush } = useSmoothText(answer, { done: !streaming })
useEffect(() => {
if (skipped) flush()
}, [skipped, answer, flush])
```
### Knowing when an answer is on screen
`phase` goes `idle`, then `streaming`, then `settling` once the transport has closed
and the tail is draining, then `done`. `done` is the moment to show copy buttons or
announce the answer to screen readers. History starts out `done`, so if you react to
the transition rather than the value, loaded messages stay quiet:
```tsx
const previous = useRef(phase)
useEffect(() => {
if (phase === 'done' && previous.current !== 'done') onComplete()
previous.current = phase
}, [phase, onComplete])
```
## useSmoothStream: the full state
Same arguments as `useSmoothText`, but it returns the whole state on every change:
`text`, `cursor`, `source`, `pending` (characters received but not shown yet),
`phase` and `charsPerSecond`. It re-renders more often; use it for debugging, charts
or a custom indicator.
## useSmoothStreamController: when you have deltas
If your transport hands you chunks and you would rather not accumulate them yourself:
```tsx
const { text, phase, push, end, reset } = useSmoothStreamController()
// push(delta) for every chunk, end() when the stream closes,
// reset() before the next answer, reset(fullText) to show a finished one.
```
## SmoothText: pacing and reveal in one element
For plain text, without your own renderer, `SmoothText` paces the text and fades new
characters in:
```tsx
import { SmoothText } from 'smooth-stream-text/react'
```
`onSettled` fires once per answer, when the last character has finished animating.
An answer unmounted before then is never announced; one hidden by `` is
announced when it is shown again. Every pacing and [reveal option](https://smooth-stream-text.vercel.app/docs/api.md#reveal-options)
is a prop. `className` styles the host, `unitClassName` goes on every animating unit,
and other props (`id`, `aria-*`, `style`, a `ref`) go to the host element.
## Reveal: the fade-in on your own markup
The reveal is a separate layer, so it can sit on the pieces your renderer produces:
```tsx
import { Reveal, RevealProvider, useSmoothText } from 'smooth-stream-text/react'
function Answer({ answer, streaming, messageId }) {
const { text } = useSmoothText(answer, { done: !streaming })
const blocks = parseMarkdown(text) // your parser
return (
{blocks.map((block, i) =>
block.type === 'paragraph' ? (
{block.text}
) : (
),
)}
)
}
```
This snippet is a sketch: `parseMarkdown` and `CodeBlock` stand for your own code.
`RevealProvider` shares one wave across everything below it, so the stagger stays
continuous when an answer is split into blocks. `Reveal` takes a **string** and
animates only what was appended since its last render. Two rules make it safe with a
parser that rebuilds its tree:
- A freshly mounted `Reveal` treats its text as already on screen, so a recreated
node does not replay. Pass `initialText=""` when you create an element for text
that is genuinely new and want it to animate.
- If the text is no longer an extension of what was shown (the parser re-split a
paragraph), it reseeds silently instead of animating text the reader already saw.
If your renderer animates new words itself, as Streamdown can, skip the reveal and
use only the pacing. Two layers animating the same words fight each other.
## Lifecycle
The stream is created once per component instance and paused, not destroyed, when the
component unmounts. That is what makes React StrictMode's mount, unmount, mount
sequence and an `` boundary harmless: the second mount carries on where the
first stopped. A paused stream draws nothing and keeps its buffer.
On the server, hooks return the text that is already done (history) on the first
render, so it is in the server HTML; `Reveal` and `SmoothText` render their text as
ordinary markup. Nothing is scheduled on the server.
## Complete example
[`examples/react-markdown`](https://github.com/crmapache/smooth-stream-text/tree/main/examples/react-markdown)
is a runnable Vite app with a simulated stream, history, Stop and Show all. The whole
integration is this component:
`examples/react-markdown/src/Answer.tsx`:
````tsx
import { useEffect, useRef, useState } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { useSmoothText } from 'smooth-stream-text/react'
import type { Message } from './types'
interface AnswerProps {
message: Message
onComplete?: () => void
}
export function Answer({ message, onComplete }: AnswerProps) {
// `text` is a prefix of `message.text` that grows at an even rate. A message that
// is already finished on its first render is history and appears at once.
const { text, phase, flush } = useSmoothText(message.text, { done: !message.streaming })
// `flush` shows what has arrived so far; later text would be paced again. After
// "Show all" the reader wants no delay at all, so every new chunk is flushed too.
const [skipped, setSkipped] = useState(false)
useEffect(() => {
if (skipped) flush()
}, [skipped, message.text, flush])
// Announce the moment a new answer is fully on screen; history starts out done and stays quiet.
const previous = useRef(phase)
useEffect(() => {
if (phase === 'done' && previous.current !== 'done') onComplete?.()
previous.current = phase
}, [phase, onComplete])
return (
{text}
{phase !== 'done' && phase !== 'idle' && !skipped && (
)}
)
}
````
And the part of the app that owns the transport:
`examples/react-markdown/src/App.tsx` (excerpt):
````tsx
async function send(event: SubmitEvent) {
event.preventDefault()
const question = draft.trim()
if (!question || streaming) return
const id = crypto.randomUUID()
setDraft('')
setAnnouncement('')
setMessages((list) => [
...list,
{ id: crypto.randomUUID(), role: 'user', text: question, streaming: false },
{ id, role: 'assistant', text: '', streaming: true },
])
controller.current = new AbortController()
const answer = answers[asked.current++ % answers.length]
try {
for await (const delta of simulateStream(answer, { signal: controller.current.signal })) {
update(id, (message) => ({ ...message, text: message.text + delta }))
}
} catch {
// Stopped by the user: keep what arrived.
} finally {
// Closing the answer lets the paced text drain to the end within about a second.
update(id, (message) => ({ ...message, streaming: false }))
controller.current = null
}
}
````
---
# Vue and Nuxt
> Pace streamed LLM text in Vue 3 and Nuxt with useSmoothStream and keep your own Markdown rendering. Refs and getters, the SmoothText component, KeepAlive, and server-rendered history in Nuxt.
>
> smooth-stream-text 0.2.2. HTML version: https://smooth-stream-text.vercel.app/docs/vue
```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
```
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
```
`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](https://smooth-stream-text.vercel.app/docs/react.md#reveal-the-fade-in-on-your-own-markup) 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 ``, 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`](https://github.com/crmapache/smooth-stream-text/tree/main/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:
`examples/nuxt/app/components/AssistantMessage.vue`:
````vue
````
---
# Svelte and SvelteKit
> Pace streamed LLM text in Svelte 4 and 5 and SvelteKit with the smoothStream store or the smoothText action. Runes, cleanup, Show all, and server-rendered history.
>
> smooth-stream-text 0.2.2. HTML version: https://smooth-stream-text.vercel.app/docs/svelte
```ts
import { smoothStream, smoothText, reveal } from 'smooth-stream-text/svelte'
```
Nothing is imported from Svelte, so the store and the actions work in Svelte 4 and 5.
Verified with Svelte 5.57 and SvelteKit 3.1.
## The store: the headless path
```svelte
{@html renderMarkdown($smooth.text)}
```
`smoothStream(options?)` is a readable store of the pacing state (`text`, `phase`,
`pending` and the rest). `apply(text, done)` hands over the latest text and whether it
is finished, on every change, and follows the same rules as the other bindings: text
that is already done the first time is history and appears at once; `done` closes the
answer and the rest drains within about a second. `destroy` releases it.
If your renderer accepts raw HTML from the model, sanitize it there, exactly as without
pacing.
In Svelte 4, use a reactive statement instead of the effect:
```svelte
```
### Show all
`flush()` shows everything received so far; text arriving later would be paced again.
To skip the delay for the rest of the answer, keep flushing while text is pending:
```svelte
$effect(() => {
if (showAll && $smooth.pending > 0) smooth.flush()
})
```
### Knowing when an answer is on screen
History starts out `done`. React to the change to `done`, not to the value, so that only
answers that were paced report finishing; the example below does this with a flag.
## The action: pacing and reveal in one element
For plain text, without your own renderer:
```svelte
```
The action owns the element's content, paces the text and fades new characters in.
`onSettled` is called once per answer, when it is fully on screen. Any pacing or reveal
option can go in the same object.
`use:reveal={{ text }}` adds only the fade-in to an element whose text you pace
yourself.
Actions do not run on the server, so an element with an action is empty in the server
output and fills in on the client. For server-rendered history, use the store.
## Complete example
[`examples/sveltekit`](https://github.com/crmapache/smooth-stream-text/tree/main/examples/sveltekit)
is a SvelteKit 3 app with Svelte 5 runes, a server route streaming Server-Sent Events,
history loaded in `+page.server.ts`, Stop and Show all. The assistant message:
`examples/sveltekit/src/lib/AssistantMessage.svelte`:
````svelte
{@html renderMarkdown($smooth.text)}
````
---
# Plain JavaScript and Web Component
> Pace and reveal streamed text without a framework using createSmoothText, or with the custom element in plain HTML, Angular, Solid, Astro or any framework that renders custom elements.
>
> smooth-stream-text 0.2.2. HTML version: https://smooth-stream-text.vercel.app/docs/dom
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](https://smooth-stream-text.vercel.app) renders Markdown this way in about
200 lines of TypeScript.
## The Web Component
```html
```
Importing the module registers ``. 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](https://smooth-stream-text.vercel.app/docs/api.md#web-component)) 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`](https://github.com/crmapache/smooth-stream-text/tree/main/examples/vanilla)
puts both on one page, fed by the same simulated stream, with Stop, Show all and Clear
chat. The imperative panel:
`examples/vanilla/src/imperative-panel.js`:
````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:
`examples/vanilla/src/web-component-panel.js`:
````js
// Registers .
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 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()
}
````
---
# AI SDK
> Pace answers from the Vercel AI SDK's useChat in the browser with smooth-stream-text. Message parts, tool calls, status, stop, errors, history, and how it relates to smoothStream().
>
> smooth-stream-text 0.2.2. HTML version: https://smooth-stream-text.vercel.app/docs/integrations/ai-sdk
The [AI SDK](https://ai-sdk.dev) streams an assistant message into `useChat` as a list
of parts. This page shows how to pace that message in the browser while keeping
your renderer. Verified with `ai` 7.0, `@ai-sdk/react` 4.0, Next.js 16.4 and
smooth-stream-text 0.2.0.
## The short version
For a message that has only text, join its text parts and pace the result:
```tsx
import { useChat } from '@ai-sdk/react'
import ReactMarkdown from 'react-markdown'
import { useSmoothText } from 'smooth-stream-text/react'
function Answer({ text, streaming }: { text: string; streaming: boolean }) {
const { text: visible } = useSmoothText(text, { done: !streaming })
return {visible}
}
export function Chat() {
const { messages, status } = useChat()
const busy = status === 'submitted' || status === 'streaming'
return messages.map((message, index) => {
const text = message.parts.map((part) => (part.type === 'text' ? part.text : '')).join('')
if (message.role !== 'assistant') return
{text}
// Only the newest answer can still be streaming; everything above it is history.
return
})
}
```
`streaming` is what matters. It is true only for the last assistant message, and
only while `status` is `submitted` or `streaming`. When the stream finishes, fails or
is stopped with `stop()`, `status` leaves those values, `done` becomes true and the
rest of the paced text drains within about a second. Messages passed to `useChat`
as initial history are done on their first render and appear at once.
## Messages with several parts
A message that calls tools has more than one text part, with tool parts between
them. Pacing each text part separately would start the second part while the first
is still being shown. Laying the text parts end to end and pacing that one string
keeps a single cursor for the whole message. Each part renders its own slice, and a
tool part shows up once the reader has reached it:
`examples/next-ai-sdk/app/assistant-message.tsx`:
````tsx
'use client'
import { getToolName, isToolUIPart, type DynamicToolUIPart, type ToolUIPart, type UIMessage } from 'ai'
import { useEffect, useRef, useState } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { useSmoothText } from 'smooth-stream-text/react'
type ToolPart = ToolUIPart | DynamicToolUIPart
type Segment = { kind: 'text'; start: number; end: number } | { kind: 'tool'; at: number; part: ToolPart }
/**
* Lays the message's text parts end to end, so one cursor paces the whole answer
* however many parts it has. A tool part sits where the text before it ends and
* shows up once the reader gets there.
*/
function layout(message: UIMessage) {
let source = ''
const segments: Segment[] = []
for (const part of message.parts) {
if (part.type === 'text') {
// A blank line between parts keeps the markdown boundary rules from joining them.
if (source) source += '\n\n'
const start = source.length
source += part.text
segments.push({ kind: 'text', start, end: source.length })
} else if (isToolUIPart(part)) {
segments.push({ kind: 'tool', at: source.length, part })
}
}
return { source, segments }
}
function ToolCard({ part }: { part: ToolPart }) {
const name = getToolName(part)
if (part.state === 'output-available') {
const { city, temperatureC, conditions } = part.output as { city: string; temperatureC: number; conditions: string }
return (
{name}: {city}, {temperatureC} °C, {conditions}
)
}
if (part.state === 'output-error') return
{name} failed: {part.errorText}
return
Running {name}…
}
interface AssistantMessageProps {
message: UIMessage
/** True while the transport may still add to this message. */
streaming: boolean
onComplete?: () => void
}
export function AssistantMessage({ message, streaming, onComplete }: AssistantMessageProps) {
const { source, segments } = layout(message)
// A message that is already done on its first render (history) appears at once.
const { text, phase, flush } = useSmoothText(source, { done: !streaming })
// `flush` shows what has arrived; after "Show all" every later chunk is flushed too.
const [skipped, setSkipped] = useState(false)
useEffect(() => {
if (skipped) flush()
}, [skipped, source, flush])
const previous = useRef(phase)
useEffect(() => {
if (phase === 'done' && previous.current !== 'done') onComplete?.()
previous.current = phase
}, [phase, onComplete])
return (
{segments.map((segment, index) => {
if (segment.kind === 'tool') return text.length >= segment.at ? : null
const visible = text.slice(segment.start, segment.end)
return visible ? (
{visible}
) : null
})}
{phase !== 'done' && phase !== 'idle' && !skipped && (
)}
)
}
````
The blank line between parts keeps the Markdown boundary rules from treating the end
of one part and the start of the next as one line. Offsets are string indices, the
same UTF-16 units the visible text uses, so slicing is exact.
## Stop, errors and retries
- `stop()` ends the stream. `status` becomes `ready`, `done` turns true, and what had
arrived drains to the end. To show all of it at once instead, call `flush()` when
the user presses Stop.
- On an error, `status` is `error`; the message is done and keeps what arrived.
`regenerate()` replaces the last answer. The new text does not continue the old
one, so the stream starts over and paces it from the beginning.
## smoothStream() on the server
The AI SDK has its own [`smoothStream()`](https://ai-sdk.dev/docs/reference/ai-sdk-core/smooth-stream)
transform for `streamText`. It re-chunks the model output (into words by default,
or lines, or chunks you define) and waits a fixed delay between chunks, 10 ms by
default. It runs wherever `streamText` runs, which in most apps is a server route.
The two are different layers, and using both adds both delays. They differ in where
they see the stream:
- `smoothStream()` evens out what the model produced. What the network then does to
the chunks between server and browser (proxies buffering, a slow mobile link) still
reaches the page unevenly.
- smooth-stream-text paces what actually arrived in the browser, and its rate follows
the backlog instead of a fixed delay, so a fast model is shown faster.
If `smoothStream()` already gives you the result you want, you do not need this
package. If you add this package, remove `experimental_transform: smoothStream()` from
the route so pacing happens once. See [choosing an approach](https://smooth-stream-text.vercel.app/docs/comparisons.md).
## Complete example
[`examples/next-ai-sdk`](https://github.com/crmapache/smooth-stream-text/tree/main/examples/next-ai-sdk)
is a Next.js App Router chat with a route handler, a tool, server-rendered history,
Stop, Show all and a retry button. It runs without an API key using an offline model
that streams with realistic timing, and uses OpenAI when `OPENAI_API_KEY` is set on
the server. The route:
`examples/next-ai-sdk/app/api/chat/route.ts`:
````ts
import { convertToModelMessages, createUIMessageStreamResponse, stepCountIs, streamText, toUIMessageStream, type UIMessage } from 'ai'
import { chatModel } from '@/lib/model'
import { tools } from '@/lib/tools'
export const maxDuration = 30
export async function POST(request: Request) {
const { messages }: { messages: UIMessage[] } = await request.json()
// No smoothStream() transform here: pacing happens once, in the browser.
// Two smoothing layers would add their delays together.
const result = streamText({
model: chatModel(),
instructions: 'You are a helpful assistant for web developers. Answer in Markdown.',
messages: await convertToModelMessages(messages),
tools,
stopWhen: stepCountIs(3),
abortSignal: request.signal,
})
return createUIMessageStreamResponse({ stream: toUIMessageStream({ stream: result.stream }) })
}
````
For the server-rendering side, see [Next.js](https://smooth-stream-text.vercel.app/docs/integrations/nextjs.md).
---
# Next.js
> Use smooth-stream-text in the Next.js App Router. Client boundaries, server-rendered conversation history that does not replay, Cache Components, hydration and remounts.
>
> smooth-stream-text 0.2.2. HTML version: https://smooth-stream-text.vercel.app/docs/integrations/nextjs
Verified with Next.js 16.4 (App Router, Cache Components on) and React 19.3.
## Where the hooks go
`smooth-stream-text/react` starts with `'use client'`, so a client component imports
it like any other client library:
```tsx
'use client'
import { useSmoothText } from 'smooth-stream-text/react'
```
The root entry, `smooth-stream-text`, has no directive and no DOM access. Server code
can import its helpers and types (for example in a route handler or a test) without
turning anything into a client component.
## History from the server
Load the conversation in a server component and pass it to the client component that
renders the chat. Finished messages arrive with `done: true` on their first render,
so:
- they are in the server HTML, already complete, because the hook returns finished
text during the server render;
- hydration matches, because the client's first render returns the same text;
- they never animate, on the server or after hydration.
Only an answer that is still streaming is paced, and that only happens on the client.
## Cache Components
With `cacheComponents: true` (the default in new Next.js 16 apps), a page is
prerendered at build time unless it reads request-time data. A chat page is usually
per visitor, and `useChat` generates ids with random values while it renders, which
Next.js refuses to bake into a prerender. Render the conversation per request:
```tsx
import { connection } from 'next/server'
import { Suspense } from 'react'
async function Conversation() {
// The conversation belongs to the visitor, so it is rendered per request.
await connection()
const messages = await loadConversation()
return
}
export default function Page() {
return (
}>
)
}
```
This is about `useChat` and per-user data, not about smooth-stream-text: the hooks
schedule nothing during a server render.
## Remounts
The stream behind each hook pauses on unmount instead of being destroyed. StrictMode
in development mounts every component twice, and an `` boundary hides and
shows components without losing state; in both cases the answer continues where it
was, with nothing replayed.
Navigating away and back creates new components. The conversation then comes back as
history (done on first render) and appears at once.
## Route handler
Keep the route as you have it. Do not add `smoothStream()` to `streamText` as well;
two smoothing layers add their delays together. The [AI SDK page](https://smooth-stream-text.vercel.app/docs/integrations/ai-sdk.md)
explains the difference.
## Complete example
[`examples/next-ai-sdk`](https://github.com/crmapache/smooth-stream-text/tree/main/examples/next-ai-sdk)
is the full app: route handler, offline model, tool calls, server-rendered history and
the client chat. The page:
`examples/next-ai-sdk/app/page.tsx`:
````tsx
import { connection } from 'next/server'
import { Suspense } from 'react'
import { loadConversation } from '@/lib/history'
import { Chat } from './chat'
async function Conversation() {
// The conversation belongs to the visitor, so it is rendered per request.
await connection()
const messages = await loadConversation()
return
}
export default function Page() {
return (
}>
)
}
````
The client chat:
`examples/next-ai-sdk/app/chat.tsx`:
````tsx
'use client'
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport, type UIMessage } from 'ai'
import { useCallback, useState, type SubmitEvent } from 'react'
import { AssistantMessage } from './assistant-message'
const transport = new DefaultChatTransport({ api: '/api/chat' })
const textOf = (message: UIMessage) => message.parts.map((part) => (part.type === 'text' ? part.text : '')).join('')
export function Chat({ initialMessages }: { initialMessages: UIMessage[] }) {
const { messages, sendMessage, status, stop, error, regenerate } = useChat({ messages: initialMessages, transport })
const [draft, setDraft] = useState('')
const [announcement, setAnnouncement] = useState('')
const busy = status === 'submitted' || status === 'streaming'
const announce = useCallback(() => setAnnouncement('Answer complete'), [])
function send(event: SubmitEvent) {
event.preventDefault()
const text = draft.trim()
if (!text || busy) return
setDraft('')
setAnnouncement('')
sendMessage({ text })
}
return (
Assistant
{messages.map((message, index) => (
{message.role === 'assistant' ? (
) : (
{textOf(message)}
)}
))}
{error && (
The answer was interrupted.{' '}
)}
{announcement}
)
}
````
---
# Streamdown
> Use smooth-stream-text for pacing and Streamdown for rendering and animation, with exactly one animation layer. When Streamdown alone is enough, reduced motion, and the Tailwind setup Streamdown needs.
>
> smooth-stream-text 0.2.2. HTML version: https://smooth-stream-text.vercel.app/docs/integrations/streamdown
[Streamdown](https://streamdown.ai) renders Markdown for streaming and can fade words in
as they mount. Its documentation describes the animation as purely visual: it does not
buffer or delay incoming chunks. When chunks arrive in uneven bursts, put pacing in
front of it and let Streamdown keep the animation. Verified with Streamdown 2.7.0,
React 19.3 and smooth-stream-text 0.2.0.
## The integration
```tsx
import { Streamdown } from 'streamdown'
import { useSmoothText } from 'smooth-stream-text/react'
function Answer({ text, streaming }: { text: string; streaming: boolean }) {
const { text: visible, phase } = useSmoothText(text, { done: !streaming })
return (
{visible}
)
}
```
One layer decides how much text is visible (the pacing) and one decides how new words
look (Streamdown). Do not add this package's `Reveal` or `SmoothText` here: two layers
animating the same words fight each other.
`isAnimating` follows the pacing phase rather than your transport status, so Streamdown
keeps animating while the paced tail drains after the stream has closed. History is
done on its first render, so it renders without animation.
## Details worth knowing
- When `isAnimating` turns false, Streamdown removes its animation wrappers, so the last
few words skip the rest of their fade. With the default 150 ms animation this is
barely visible.
- Streamdown's fade does not check `prefers-reduced-motion`. Add a rule for readers who
ask for less motion; the example uses
`@media (prefers-reduced-motion: reduce) { [data-sd-animate] { animation: none } }`.
The pacing keeps running.
- Streamdown's components are styled with Tailwind utilities. With Tailwind 4, point it
at Streamdown's files with `@source` and define the color tokens its classes use, as
the example's `src/index.css` does.
## When Streamdown alone is enough
If your streams already arrive at a fairly even rhythm (some providers and the AI SDK's
`smoothStream()` on the server produce that), Streamdown's animation alone may look
right, and you do not need this package. The pacing earns its place when chunks arrive
in bursts and pauses, which the reader otherwise sees as text jumping forward.
## Complete example
[`examples/react-streamdown`](https://github.com/crmapache/smooth-stream-text/tree/main/examples/react-streamdown)
is a Vite app with a simulated stream, history, Stop and Show all:
`examples/react-streamdown/src/AssistantMessage.tsx`:
````tsx
import { useEffect, useState } from 'react'
import { useSmoothText } from 'smooth-stream-text/react'
import { Streamdown } from 'streamdown'
interface AssistantMessageProps {
/** Everything received so far. */
text: string
/** Whether the transport is still sending this answer. */
streaming: boolean
/** Called once, when an answer that started live is completely on screen. */
onComplete: () => void
}
export function AssistantMessage({ text, streaming, onComplete }: AssistantMessageProps) {
// The pacing. `shown` is a prefix of `text` that grows at an even rate. A
// message that is already finished on its first render is history: it comes
// back complete, with phase 'done', and never animates.
const { text: shown, phase, flush } = useSmoothText(text, { done: !streaming })
const [startedLive] = useState(streaming)
const [showAll, setShowAll] = useState(false)
// flush() shows everything received so far. Repeating it as more arrives
// keeps the rest of this answer unpaced too.
useEffect(() => {
if (showAll) flush()
}, [showAll, text, flush])
useEffect(() => {
if (startedLive && phase === 'done') onComplete()
}, [startedLive, phase, onComplete])
return (
<>
{/* Streamdown owns the fade. It animates only while the answer is being paced. */}
{shown}
{phase !== 'done' && !showAll && (
)}
>
)
}
````
---
# Latency and completion
> What pacing costs in time, how the tail of an answer drains, the difference between transport done, pacing done and reveal settled, background tabs, Show all, and why two smoothing layers add up.
>
> smooth-stream-text 0.2.2. HTML version: https://smooth-stream-text.vercel.app/docs/concepts/latency
Pacing trades a small, steady delay for an even flow. This page explains where that
delay comes from, how long the end of an answer takes, and how to control both.
The [benchmarks](https://smooth-stream-text.vercel.app/docs/benchmarks.md) put numbers on it for recorded streams.
## How the rate is chosen
Every frame the controller computes the rate that would drain the current backlog
over the target latency, then eases the actual rate towards it:
```ts
const desired = pending / targetLatencyMs
const alpha = 1 - Math.exp(-dt / adaptMs)
rate += (desired - rate) * alpha
```
So the display runs roughly `targetLatencyMs` (180 ms by default) behind the text
that has arrived. A fast producer builds a bigger backlog and is drained faster; a
slow one is drained slower. A twenty-word burst becomes a short acceleration rather
than a wall of text, because the rate is smoothed, not the backlog. The rate is held
between `minCharsPerSecond` (12) and `maxCharsPerSecond` (900).
Pacing never makes the model faster. It decides how much of what already arrived is
visible.
## Three different "done"s
| Moment | Who knows | What it means |
| --- | --- | --- |
| Transport done | Your code | The last chunk has arrived. Pass `done: true` (or call `end()`). |
| Pacing done | `phase === 'done'` | All received text is visible. |
| Reveal settled | `onSettled`, `settled`, `sst:done` | The last unit has finished its fade-in. Only with the reveal. |
They happen in that order, a little apart. Show copy buttons or the next prompt on
pacing done when you render text yourself, or on settled when you use `SmoothText`,
`createSmoothText` or the Web Component.
## The tail
When the transport closes the answer, the target latency decays to zero over
`settleMs` (400 ms), so the tail accelerates into a stop instead of crawling the last
few words. An answer is complete at most twice `settleMs` after it was closed; past
that deadline the rest is shown at once.
The same happens on a stream that is still open but silent: after `idleGraceMs`
(200 ms) without input the tail drains, so text never sits waiting for a chunk that
is slow to come. When input resumes, pacing resumes.
`done` may be set before the last chunk arrives. That chunk is still shown and paced,
then the answer closes again.
## Big backlogs jump
If the backlog could not be drawn within `maxLatencyMs` (2000 ms) even at the
maximum rate, the display jumps to the end instead of catching up slowly. This covers
two common cases:
- A whole message handed over at once, for example a cached answer.
- A background tab that was away long enough. Browsers stop `requestAnimationFrame`
in hidden tabs. When the reader comes back, a backlog of more than about 1,800
characters (two seconds at 900 per second) jumps to the end; a smaller one is caught
up at the maximum rate, which takes at most two seconds. With the reveal, only the
last `maxAnimatedUnits` (64) units of a big batch animate.
## Show all
`flush()` shows everything received so far, at once. On a stream that is still open,
text that arrives afterwards is paced again. If a "Show all" button should remove the
delay for the rest of the answer, flush on every new chunk until the answer ends; the
[React guide](https://smooth-stream-text.vercel.app/docs/react.md#show-all) has the pattern.
## Two smoothing layers add up
Server-side smoothing such as the AI SDK's `smoothStream()` delays chunks before they
leave the server. Client-side pacing then holds the display behind what arrives. With
both, the reader waits for both. Pick one layer. The [comparison](https://smooth-stream-text.vercel.app/docs/comparisons.md) helps
decide which.
## Tuning
- Snappier: lower `targetLatencyMs` (for example 100). Bursts are then less smooth.
- Smoother: raise `targetLatencyMs` (for example 250) or `adaptMs`.
- Whole words instead of characters: `unit: 'word'`.
- Faster tail: lower `settleMs`.
Every option can change at runtime with `setOptions`, or as new props in React and
Vue. `useSmoothStream` (React) and `subscribe` (core) expose `pending` and
`charsPerSecond` if you want to watch the controller work; the
[playground](https://smooth-stream-text.vercel.app) charts both live.
---
# Text boundaries
> Where smooth-stream-text lets the visible text end - grapheme clusters, emoji, CJK, Thai and Arabic, whole words, and Markdown delimiters - and the limits of those guarantees.
>
> smooth-stream-text 0.2.2. HTML version: https://smooth-stream-text.vercel.app/docs/concepts/text-boundaries
The visible text is always a prefix of what arrived. Where that prefix may end
decides whether a renderer ever shows half an emoji, a lone `*` or the first half of
a URL. These rules apply only to where the cursor stops; the text itself is never
changed.
## Grapheme clusters, in every mode
The visible text never ends inside a grapheme cluster: not inside a surrogate pair, a
combining mark, a flag, an emoji ZWJ sequence such as 👩💻 or 👨👩👧, a Hangul syllable
or an Indic conjunct. Clusters follow Unicode's own rules (UAX #29) through
`Intl.Segmenter`. Where `Intl.Segmenter` is missing (Chrome before 87, Safari before
14.1, Firefox before 125), a smaller hand-written set of rules covers the common
cases.
While a stream is live, the last cluster may still be growing: a combining mark or
the second half of a pair can still arrive. It waits until the producer goes quiet or
more text follows. A chunk that ends in the middle of a surrogate pair therefore never
shows a glyph that then changes.
## Characters or whole words
`unit` decides what appears as one piece.
- `'char'` (the default) lets the text grow a character at a time. With the reveal,
each character fades in place as plain inline text, so line breaking and joined
scripts such as Arabic are never disturbed.
- `'word'` only ever adds whole words. In space-separated languages a word ends where
whitespace starts or ends, so punctuation, contractions and URLs stay with their
word. Chinese, Japanese, Thai and other scripts written without spaces are split at
dictionary words through `Intl.Segmenter`. Because the dictionary can change its
mind at the live edge, such a word waits until something outside its script follows
or enough further text arrives.
With `'word'`, the last, possibly unfinished word of a live stream waits until the
producer has been silent for `holdMs`.
## Markdown boundaries
`boundary: 'markdown'`, the default, keeps the visible prefix from being something a
Markdown parser would render wrongly:
- never inside a delimiter run: `**`, `~~`, a code fence, the `](` of a link;
- never while an inline construct is open: emphasis, inline code, a link, a
line-leading `#`, `-`, `>` or `1.` before its first word, a table row before its
newline;
- never inside a fence marker. The body of a fenced code block is not held, because
holding it until the closing fence would stall the answer for thousands of
characters.
Two safety valves keep a hold from becoming a freeze:
- if the producer has been silent for `holdMs` (1500 ms), the closing delimiter is
not coming and the held text is released;
- if a hold would withhold more than `holdChars` (120) characters, the construct is
not closing soon, and the cursor carries on at its normal pace with the markup
shown literally.
The scanner is a pragmatic approximation, not a CommonMark parser. Inline state
resets at every newline, which bounds each hold to one line. A false positive costs a
short hold and nothing else.
## Other modes
| `boundary` | Stops at |
| --- | --- |
| `'markdown'` | Grapheme clusters, outside open Markdown constructs. |
| `'char'` | Any grapheme cluster. No Markdown rules. |
| `'word'` | Whole words. No Markdown rules. |
| a function | Whatever you return. |
A custom boundary is `(source, desired, closed) => number` and returns the largest
allowed index at or before `desired`. `closed` says whether the producer has closed
the stream.
## What this is not
- **Not a Markdown parser.** It decides where the visible text may end. Rendering,
syntax highlighting and Markdown extensions stay with your renderer.
- **Not a sanitizer.** The visible text is a prefix of what arrived, byte for byte.
If your renderer accepts raw HTML, sanitizing it is the renderer's job, exactly as
without pacing.
- **Not a token counter.** Positions and rates are UTF-16 code units, the way
JavaScript string lengths count. A "character per second" here is a code unit per
second, which is not a model token.
---
# Accessibility
> Reduced motion, screen readers, selection and keyboard behaviour with smooth-stream-text, and what stays the responsibility of your renderer.
>
> smooth-stream-text 0.2.2. HTML version: https://smooth-stream-text.vercel.app/docs/accessibility
Pacing changes when text appears, and the optional reveal changes how. Both affect
people using assistive technology or reduced-motion settings. This page describes
what the package does on its own and what your app needs to do.
## Reduced motion
With the reveal (`SmoothText`, `Reveal`, `createSmoothText`, `createReveal`, the Web
Component), `respectReducedMotion` is `true` by default. When the system or browser
asks for reduced motion, no animated spans are created at all: the text is simply
extended. Switching the preference in the middle of an answer keeps the text in
order. Set `respectReducedMotion: false` only if your app offers its own setting.
Pacing itself is not an animation: the text still grows over a fraction of a second
behind the stream. If you want people who ask for reduced motion to see text exactly
as it arrives, render the received text directly for them:
```tsx
import { prefersReducedMotion } from 'smooth-stream-text/dom'
const reduce = prefersReducedMotion()
const { text } = useSmoothText(answer, { done: !streaming })
return {reduce ? answer : text}
```
`prefersReducedMotion()` reads the media query once; subscribe to
`matchMedia('(prefers-reduced-motion: reduce)')` if you need to follow changes.
## Screen readers
Do not put `aria-live` on the element whose text grows. A live region announces every
change, which for a paced answer means a stream of fragments.
Announce the finished answer once instead:
- Mark the message `aria-busy="true"` while it is streaming, if your design allows.
- When the answer is fully on screen, write a short notice ("Answer complete", or the
answer itself if it is short) into a visually hidden polite live region. Use
`phase === 'done'` when you render text yourself, or `onSettled` / `settled` /
`sst:done` with the reveal.
React to the transition to `done`, not to the value, so loading a conversation (which
starts out done) does not announce every old message. The
[React guide](https://smooth-stream-text.vercel.app/docs/react.md#knowing-when-an-answer-is-on-screen) shows the pattern, and every
example app does this.
## Selection and copying
With the reveal, a unit lives in a `` only while it animates; afterwards it is
folded back into the surrounding text node. A finished answer is plain text with no
spans, however long it is. While text is arriving, the spans still animating stay in
the DOM: a few dozen for a typical answer, a few hundred at the peak of a very fast
stream (see the [benchmarks](https://smooth-stream-text.vercel.app/docs/benchmarks.md#rendering-cost-in-a-browser)). A selection
copies exactly the characters on screen, and a selection made while the answer is
still arriving survives the folding.
## Keyboard and focus
The package adds no focusable elements and never moves focus. Controls such as Stop
or Show all belong to your app; give them visible labels and keep them reachable while
an answer streams.
## What stays with your renderer
The visible text is a prefix of what arrived, unchanged. Headings, lists, link text,
table semantics and HTML sanitization are whatever your renderer produces, exactly as
without pacing. If your renderer accepts raw HTML from the model, sanitize it there.
## Limits
These behaviours are covered by the package's tests (reduced motion, selection
folding) and by the example apps' end-to-end tests (completion announcements). That
is not an accessibility audit of your app. Test your own chat with the screen readers
your users have.
---
# API reference
> Complete API reference for smooth-stream-text 0.2 - entry points, the headless core, pacing and reveal options with defaults, the DOM handle, React hooks and components, Vue, Svelte and the Web Component.
>
> smooth-stream-text 0.2.2. HTML version: https://smooth-stream-text.vercel.app/docs/api
This page documents version 0.2.2, the current release on npm.
## Entry points
| Import | Contains | Needs |
| --- | --- | --- |
| `smooth-stream-text` | The core: pacing, clocks, boundaries, source sync | Nothing. No DOM, runs in Node 18+ |
| `smooth-stream-text/dom` | The reveal and the DOM handle, plus everything from the root | A DOM |
| `smooth-stream-text/react` | Hooks and components, plus the DOM and root exports | React 18 or 19 |
| `smooth-stream-text/vue` | Composable and components, plus the DOM and root exports | Vue 3.2+ |
| `smooth-stream-text/svelte` | Store and actions, plus the DOM and root exports | Nothing from Svelte is imported |
| `smooth-stream-text/web-component` | ``, plus the DOM and root exports | Custom elements |
| `smooth-stream-text/styles.css` | The reveal stylesheet, for `injectStyles: false` | |
Every entry ships ES modules, CommonJS and type declarations. The React entry starts
with `'use client'`. Importing the Web Component entry on a server does not throw.
Positions, lengths and rates count **UTF-16 code units**, the way JavaScript string
lengths do, not visible characters or tokens.
## Core
```ts
import { createSmoothStream } from 'smooth-stream-text'
const stream = createSmoothStream({ targetLatencyMs: 150 })
```
### createSmoothStream(options?): SmoothStream
Creates one stream. A stream can serve any number of answers: `end()` closes the
current answer, and text that arrives later reopens it.
| Method | What it does |
| --- | --- |
| `push(delta)` | Appends a delta. |
| `setSource(text)` | Hands over the growing full text. Text that does not start with the current source counts as a new answer and starts over. |
| `end()` | The producer closed the answer: the tail drains and the phase reaches `done`. |
| `reset(text?)` | Starts over. With `text`, starts with it fully visible. For finished history, follow with `end()`, or the phase stays open until text arrives. |
| `flush()` | Shows everything received so far, at once. Later input is paced again. |
| `pause()` | Stops drawing without losing anything. Input may still arrive and waits in the buffer. |
| `resume()` | Draws again from where it stopped. |
| `setOptions(partial)` | Changes settings at runtime. A key passed as `undefined` returns to its default. `clock` is fixed at creation. |
| `getState()` | The current `SmoothState`. |
| `subscribe(listener)` | Calls `listener(state)` on every change. Returns an unsubscribe function. |
| `destroy()` | Releases the stream for good. Later calls do nothing. |
`push` and `setSource` are interchangeable; use whichever your transport gives you.
### SmoothState
| Field | Type | Meaning |
| --- | --- | --- |
| `text` | `string` | The visible prefix. Render this. |
| `cursor` | `number` | How much of `source` is visible. |
| `source` | `string` | Everything received for the current answer. |
| `pending` | `number` | `source.length - cursor`. |
| `phase` | `StreamPhase` | `'idle'`, `'streaming'`, `'settling'` or `'done'`. |
| `charsPerSecond` | `number` | The controller's current rate. |
`settling` means the producer has stopped (closed, or silent past `idleGraceMs`) and
the tail is draining. `done` means the answer is closed and fully visible.
### Pacing options
| Option | Default | Meaning |
| --- | --- | --- |
| `targetLatencyMs` | `180` | How far behind the live text the display runs, in ms. The main feel setting. |
| `adaptMs` | `250` | Time constant for smoothing the rate. Larger means lazier acceleration; `0` means none. |
| `minCharsPerSecond` | `12` | Floor on the rate; `0` means none. |
| `maxCharsPerSecond` | `900` | Ceiling on the rate. Keeps a burst from being dumped at once. |
| `settleMs` | `400` | How long the tail takes to drain once the producer stops. The hard deadline is twice this. |
| `idleGraceMs` | `200` | Silence tolerated on an open stream before the tail drains anyway. |
| `maxLatencyMs` | `2000` | Above this projected lag the display jumps instead of catching up. |
| `maxFrameMs` | `100` | Per-tick clamp on elapsed time, so one stalled frame cannot cause a jump. |
| `boundary` | `'markdown'` | Where the visible text may end: `'markdown'`, `'char'`, `'word'` or a function. See [text boundaries](https://smooth-stream-text.vercel.app/docs/concepts/text-boundaries.md). |
| `unit` | `'char'` | `'char'` or `'word'`: what appears as one piece. The reveal reads the same key. |
| `holdMs` | `1500` | How long the producer must be silent before a held construct or a half-arrived word is released. |
| `holdChars` | `120` | Most characters a boundary may hold back before releasing them anyway. |
| `clock` | rAF | The time source. `requestAnimationFrame` where it exists, a 16 ms timer otherwise. |
A value that cannot work (`NaN`, a ceiling of zero or below, a floor above the
ceiling) falls back to its default or is clamped, so the stream never freezes.
`resolvePacingOptions(options)` returns the full, checked set; `defaultPacing` holds
the defaults.
### Clocks and testing
`createManualClock(startMs?)` returns a clock you advance by hand, so pacing can be
tested without waiting:
```ts
import { createManualClock, createSmoothStream } from 'smooth-stream-text'
const clock = createManualClock(0)
const stream = createSmoothStream({ clock })
stream.push('hello world')
stream.end()
for (let i = 0; i < 100; i++) clock.advance(16)
stream.getState().text // 'hello world'
```
A `Clock` is `{ now(): number; schedule(callback): cancel }`. `defaultClock` is the
built-in one. `ManualClock` adds `advance(ms)` and `setNow(ms)`.
### createSourceSync(target)
The rule every binding uses to turn a `(text, done)` pair, as a component receives it
on each render, into stream calls. `apply(text, done)`:
- the first pair that is already done is history: `reset(text)` and `end()`;
- text that continues the source is handed over and paced; `done` closes the answer,
including when a chunk arrives after `done` was set;
- text that replaces the source is shown at once if done, or paced from the start if
not.
Use it when you write a binding for another framework. `SyncTarget` is the shape it
drives: `getState`, `setSource`, `reset` and `end`.
### Lower-level helpers
These are exported for custom pipelines and are what the stream itself uses.
| Export | Purpose |
| --- | --- |
| `createBoundaryResolver(mode, unit?)` | Returns `{ resolve(source, desired, closed, quiet?), reset() }`, the largest allowed cut at or before `desired`. |
| `clampToGraphemeBoundary(text, index)` | Moves an index back to the nearest grapheme cluster boundary. |
| `createPacerState(options, cursor?)`, `stepPacer(state, input, options)`, `effectiveTargetLatency(input, options)` | The rate controller, one tick at a time. |
Types: `BoundaryFn`, `BoundaryMode`, `BoundaryResolver`, `TextUnit`, `PacerInput`,
`PacerState`, `SourceSync`, `SyncTarget`, `Clock`, `ManualClock`, `PacingOptions`,
`ResolvedPacing`, `SmoothState`, `SmoothStream`, `StreamPhase`.
## Reveal options
The reveal fades newly visible text in. It is used by `SmoothText`, `Reveal`,
`createSmoothText`, `createReveal` and the Web Component.
| Option | Default | Meaning |
| --- | --- | --- |
| `unit` | `'char'` | What animates as one unit: `'char'` or `'word'`. Shared with pacing. |
| `durationMs` | `380` | How long one unit takes to appear. |
| `staggerMs` | `36` | Delay between neighbouring units of one batch. |
| `blurPx` | `4` | Starting blur. |
| `translatePx` | `3` | Starting upward shift. `0` keeps units `display: inline`, with no layout impact. |
| `easing` | `cubic-bezier(.22,.61,.36,1)` | Timing function. |
| `maxWaveLagMs` | `240` | Above this queue depth the stagger compresses, so a batch cannot queue a long wave. |
| `maxAnimatedUnits` | `64` | Most units one update animates. Anything before them in a bigger batch appears at once. |
| `respectReducedMotion` | `true` | Under `prefers-reduced-motion` no spans are created at all. `false` animates anyway. |
| `injectStyles` | `true` | `false` skips the built-in stylesheet; import `smooth-stream-text/styles.css` or copy `REVEAL_CSS` instead. |
| `className` | | Extra class on every animating unit. |
The stylesheet is a constructed stylesheet in the cascade layer `smooth-stream-text`,
adopted by the document or the shadow root the element is in, so a Content Security
Policy without `'unsafe-inline'` does not block it and any rule of yours wins.
Visual settings are CSS custom properties on the host: `--sst-duration`, `--sst-blur`,
`--sst-translate`, `--sst-easing`.
## DOM
```ts
import { createSmoothText, createReveal } from 'smooth-stream-text/dom'
```
### createSmoothText(element, options?): SmoothTextHandle
Pacing and reveal in one element, for plain text. The handle owns the element's
children; give it an element with no other content. `options` takes pacing options,
reveal options, a shared `timeline`, a `clock` and `initialText`.
| Member | What it does |
| --- | --- |
| `push(delta)`, `setText(text)` | Hand over a delta, or the full growing text. |
| `end()` | Closes the current answer. |
| `reset(text?)` | Starts over, with `text` already fully shown. |
| `flush()` | Shows everything received, at once. |
| `pause()`, `resume()` | Stop and continue drawing, for an element that is hidden or detached. |
| `setOptions(partial)` | Changes pacing or reveal settings at runtime. |
| `subscribe(listener)` | The pacing state on every change. |
| `onSettled(listener)` | Called once per answer when it is closed, fully paced and no unit is still animating. Returns an unsubscribe function. |
| `settled` | Whether the current answer is completely on screen. |
| `state` | The current pacing state. |
| `destroy()` | Releases everything. The text stays on screen. |
### createReveal(element, options?): RevealHandle
The reveal alone, for text you pace yourself. `update(fullVisibleText)` hands over
the whole visible text; the diff against what is shown is computed inside, and only
appended text animates. Also `flush()`, `reset(text?)`, `setOptions(partial)`,
`animating`, `onIdle(listener)` and `destroy()`.
`createWaveTimeline(options)` creates a timeline that several reveals can share, so
the stagger stays continuous across paragraphs. Pass it as `timeline`.
Also exported: `defaultReveal`, `resolveRevealOptions`, `splitPieces`, `REVEAL_CSS`,
`WORD_CLASS` (`'sst-word'`), `FLAT_CLASS` (`'sst-flat'`), `FORCE_MOTION_CLASS`
(`'sst-motion'`), `ensureStyles(target)`, `applyRevealVars(element, options)`,
`prefersReducedMotion()`, and the types `Piece`, `ResolvedReveal`, `RevealDeps`,
`RevealHandle`, `RevealOptions`, `WaveTimeline`, `SmoothTextHandle`,
`SmoothTextOptions`.
## React
```ts
import { useSmoothText, useSmoothStream, useSmoothStreamController, SmoothText, Reveal, RevealProvider, useReveal } from 'smooth-stream-text/react'
```
| Export | Signature | Returns |
| --- | --- | --- |
| `useSmoothText` | `(source, options?: PacingOptions & { done?: boolean })` | `{ text, phase, flush, reset, setOptions }`. Re-renders only when `text` or `phase` changes. |
| `useSmoothStream` | same | The full `SmoothState` plus `flush`, `reset`, `setOptions`. |
| `useSmoothStreamController` | `(options?: PacingOptions)` | The full state plus `push`, `setSource`, `end`, `flush`, `reset`, `setOptions`. |
| `SmoothText` | props: `text`, `done?`, `as?`, `className?`, `unitClassName?`, `onSettled?`, every pacing and reveal option, any HTML attribute | A host element that paces and reveals its text. |
| `RevealProvider` | props: every reveal option, `resetKey?` | Shares one wave timeline with the `Reveal`s below it. Renders no DOM. |
| `Reveal` | props: `children: string`, `as?`, `initialText?`, any HTML attribute | Animates appended text in its host element. |
| `useReveal` | `()` | The surrounding provider's `{ options, timeline }`. |
Types: `SmoothStreamControls`, `SmoothStreamController`, `SmoothTextState`,
`SmoothTextProps`, `RevealProps`, `RevealProviderProps`, `RevealContextValue`.
The React entry also re-exports everything from the DOM and root entries.
## Vue
```ts
import { useSmoothStream, SmoothText, Reveal, RevealProvider } from 'smooth-stream-text/vue'
```
| Export | Signature | Returns |
| --- | --- | --- |
| `useSmoothStream` | `(source: Ref \| (() => string), options?: PacingOptions & { done?: Ref \| (() => boolean) \| boolean })` | `{ state, text, phase, flush, reset, pause, resume, setOptions }`; `text` and `phase` are computed refs. |
| `SmoothText` | props: `text`, `done`, `as` (default `'div'`), `className`, `options`; emits `settled` | Paces and reveals plain text. Also the default export. |
| `RevealProvider` | props: `options`, `resetKey` | Shares one wave with the `Reveal`s in its slot. |
| `Reveal` | props: `text`, `as` (default `'span'`), `className`, `options` | Animates appended text. |
Pacing starts once the component is mounted, so server rendering schedules nothing;
text that is already done is in the server output. Inside `` everything
pauses while deactivated. The Vue entry re-exports the DOM and root entries.
## Svelte
```ts
import { smoothStream, smoothText, reveal } from 'smooth-stream-text/svelte'
```
| Export | Kind | What it does |
| --- | --- | --- |
| `smoothStream(options?)` | readable store of `SmoothState` | Adds `apply(text, done)`, `push`, `setSource`, `end`, `flush`, `reset`, `pause`, `resume`, `setOptions`, `destroy`. |
| `smoothText` | action | `use:smoothText={{ text, done, onSettled, ...options }}` paces and reveals into the element. |
| `reveal` | action | `use:reveal={{ text, ...revealOptions }}` animates appended text in the element. |
Nothing is imported from Svelte, so these work in Svelte 4 and 5. Actions do not run
on the server. Types: `SvelteAction`, `SmoothTextActionOptions`,
`RevealActionOptions`, `SmoothStreamStore`.
## Web Component
```ts
import 'smooth-stream-text/web-component'
```
Importing the module defines ``. `defineSmoothStreamTextElement(tag?)`
defines it under another name; `SmoothStreamTextElement` is the class.
| Attribute | Option |
| --- | --- |
| `text` | the text (also a property) |
| `done` | closes the answer (also a property) |
| `unit` | `unit` |
| `duration` | `durationMs` |
| `stagger` | `staggerMs` |
| `blur` | `blurPx` |
| `translate` | `translatePx` |
| `target-latency` | `targetLatencyMs` |
| `min-cps` | `minCharsPerSecond` |
| `max-cps` | `maxCharsPerSecond` |
| `boundary` | `boundary` |
Properties: `text`, `done`, `options` (any pacing or reveal option; removing an
attribute brings back the option or default underneath it) and the read-only `state`.
Methods: `push(delta)`, `end()`, `flush()`, `reset(text?)`.
The element dispatches `sst:done` (bubbling) once per answer, when it is completely on
screen. The stream belongs to the element: moving it or taking it out of the page and
back pauses and resumes it, and text pushed before the element is connected is kept.