Streamdown
Streamdown 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
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 (
<Streamdown animated isAnimating={phase !== 'done'}>
{visible}
</Streamdown>
)
}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
isAnimatingturns 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
@sourceand define the color tokens its classes use, as the example'ssrc/index.cssdoes.
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 is a Vite app with a simulated stream, history, Stop and Show all:
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 (
<>
<div data-testid="assistant-message" data-phase={phase}>
{/* Streamdown owns the fade. It animates only while the answer is being paced. */}
<Streamdown animated isAnimating={phase !== 'done'}>
{shown}
</Streamdown>
</div>
{phase !== 'done' && !showAll && (
<button type="button" className="button mt-3" onClick={() => setShowAll(true)}>
Show all
</button>
)}
</>
)
}