Skip to main content
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
}
OptionDefaultDescription
animation"fadeIn"Named animation. Built-ins: fadeIn, blurIn, slideUp. Custom strings are prefixed with sd- to match @keyframes.
duration150Animation duration in milliseconds per unit.
easing"ease"CSS timing function passed to animation-timing-function.
sep"word"Split text by "word" or "char".
stagger40Additional 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>

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:
  1. The transformer visits every text node in the HAST tree
  2. It splits each node into <span data-sd-animate> elements (words or characters)
  3. Each span receives CSS custom properties: --sd-animation, --sd-duration, --sd-easing, and an optional --sd-delay for staggering
  4. Text inside code, pre, svg, math, and annotation elements is skipped to avoid breaking their layout
  5. 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:
PropertyDescription
--sd-animationThe @keyframes name (prefixed with sd-)
--sd-durationDuration in milliseconds (0ms for already-visible content)
--sd-easingCSS timing function
--sd-delayStagger 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.

Build docs developers (and LLMs) love