Streamdown can animate new text as it arrives during streaming. Words (or characters) fade in with a CSS animation, creating a smooth reveal effect. When streaming ends, the animated <span> wrappers are removed entirely — completed messages have zero extra DOM overhead.
Enabling animation
Import the animation CSS and pass the animated prop:
import { Streamdown } from "streamdown";
import "streamdown/styles.css";
export default function ChatMessage({ text, isStreaming }) {
return (
<Streamdown animated isAnimating={isStreaming}>
{text}
</Streamdown>
);
}
animated accepts true (use defaults) or an AnimateOptions object for fine-grained control.
AnimateOptions
All fields are optional. Defaults match the values shown below.
interface AnimateOptions {
animation?: "fadeIn" | "blurIn" | "slideUp" | (string & {});
duration?: number; // milliseconds, default: 150
easing?: string; // CSS timing function, default: "ease"
sep?: "word" | "char"; // split granularity, default: "word"
stagger?: number; // delay between units in ms, default: 40
}
| Option | Default | Description |
|---|
animation | "fadeIn" | Named animation. Built-ins: fadeIn, blurIn, slideUp. Custom strings are prefixed with sd- to match @keyframes. |
duration | 150 | Animation duration in milliseconds per unit. |
easing | "ease" | CSS timing function passed to animation-timing-function. |
sep | "word" | Split text by "word" or "char". |
stagger | 40 | Additional delay added per unit to create a cascading effect. |
Built-in animations
A simple opacity transition from invisible to visible. Good default for most use cases.<Streamdown animated={{ animation: "fadeIn" }} isAnimating={isStreaming}>
{text}
</Streamdown>
Combines opacity with a blur-to-sharp transition. Works especially well with fast-streaming models where many tokens arrive at once — the blur masks the batch appearance better than opacity alone.<Streamdown animated={{ animation: "blurIn" }} isAnimating={isStreaming}>
{text}
</Streamdown>
Words fade in while sliding up 4 px, creating a subtle rising effect.<Streamdown animated={{ animation: "slideUp" }} isAnimating={isStreaming}>
{text}
</Streamdown>
Configuration example
<Streamdown
animated={{
animation: "blurIn",
duration: 200,
easing: "ease-out",
sep: "word",
stagger: 30,
}}
isAnimating={isStreaming}
>
{text}
</Streamdown>
Character-level animation
Set sep: "char" to animate each character individually instead of whole words. This produces a typewriter-like effect but creates significantly more DOM nodes.
<Streamdown
animated={{ animation: "fadeIn", sep: "char" }}
isAnimating={isStreaming}
>
{text}
</Streamdown>
Use character animation sparingly on long responses — the per-character spans can add up.
How the animation pipeline works
The animation runs as a rehype transformer in the Streamdown plugin pipeline:
- The transformer visits every text node in the HAST tree
- It splits each node into
<span data-sd-animate> elements (words or characters)
- Each span receives CSS custom properties:
--sd-animation, --sd-duration, --sd-easing, and an optional --sd-delay for staggering
- Text inside
code, pre, svg, math, and annotation elements is skipped to avoid breaking their layout
- On the next render, previously visible characters receive
--sd-duration: 0ms so they don’t re-animate
React’s reconciliation triggers the CSS animation only for newly-mounted spans — spans that existed in the previous render are reconciled in place and stay visible.
onAnimationStart and onAnimationEnd callbacks
Use these callbacks to react to streaming state transitions:
import { useCallback } from "react";
import { Streamdown } from "streamdown";
import "streamdown/styles.css";
export default function ChatMessage({ text, isStreaming }) {
const handleStart = useCallback(() => {
console.log("Streaming started");
}, []);
const handleEnd = useCallback(() => {
console.log("Streaming ended");
}, []);
return (
<Streamdown
animated
isAnimating={isStreaming}
onAnimationStart={handleStart}
onAnimationEnd={handleEnd}
>
{text}
</Streamdown>
);
}
onAnimationStart fires when isAnimating transitions from false to true
onAnimationEnd fires when isAnimating transitions from true to false
- Both are suppressed in
mode="static"
- On the first render, only
onAnimationStart can fire (there is no prior state to trigger onAnimationEnd)
Wrap both callbacks with useCallback to avoid unnecessary effect re-runs. Streamdown stores the callbacks in refs internally, so stale closures are not a concern, but useCallback prevents React from seeing a new prop reference on every render.
Example with AI SDK
"use client";
import { useChat } from "@ai-sdk/react";
import { Streamdown } from "streamdown";
import "streamdown/styles.css";
export default function Chat() {
const { messages, input, handleInputChange, handleSubmit, status } = useChat();
return (
<div>
<form onSubmit={handleSubmit}>
<input value={input} onChange={handleInputChange} />
<button type="submit">Send</button>
</form>
{messages.map((message) => (
<div key={message.id}>
<strong>{message.role}</strong>
<Streamdown
animated={{ animation: "blurIn", duration: 200 }}
isAnimating={status === "streaming" && message.role === "assistant"}
>
{message.content}
</Streamdown>
</div>
))}
</div>
);
}
Custom animations
Define your own @keyframes and reference them by name. The animation name is automatically prefixed with sd-:
/* globals.css */
@keyframes sd-popIn {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
<Streamdown animated={{ animation: "popIn" }} isAnimating={isStreaming}>
{text}
</Streamdown>
CSS custom properties
Each animated span receives these inline CSS custom properties:
| Property | Description |
|---|
--sd-animation | The @keyframes name (prefixed with sd-) |
--sd-duration | Duration in milliseconds (0ms for already-visible content) |
--sd-easing | CSS timing function |
--sd-delay | Stagger delay in milliseconds (omitted when 0) |
The [data-sd-animate] selector in styles.css reads them:
[data-sd-animate] {
animation: var(--sd-animation, sd-fadeIn) var(--sd-duration, 150ms)
var(--sd-easing, ease) var(--sd-delay, 0ms) both;
}
Override these in your own CSS to globally adjust behavior.