Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .changeset/animation-reveal-model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
"streamdown": patch
---

Rework the streaming text animation onto a single, controller-driven reveal over
a global, document-ordered segment space.

- Sibling sections no longer animate concurrently. Exactly one chunk is active at
a time across all blocks, so a list finishes revealing before the next
paragraph or heading begins.
- Every segment reveals in order with the correct staggered delay, including
rich/inline content (bold, italics, links). Previously only the trailing
newly-arrived words animated while earlier or nested content stayed static.
Atomic elements — code blocks, images, math, and embeds — reveal as a unit.

A single shared processor/plugin tracks the reveal frontier in one
`AnimationController` over a global segment ordinal space. Already-shown words
carry stable per-segment keys, so they reconcile in place instead of remounting
or re-animating when the markdown re-parses mid-stream, and the controller is
stepped idempotently during render (no snap under React StrictMode / concurrent
rendering).

Adds an `animated={{ reserveSpace: true }}` option: by default unrevealed content
is collapsed (`display: none`) so the layout grows in as content reveals; set
`reserveSpace` to reserve the final layout up front and fade segments in place
for a shift-free layout.

Note: when `animated` is enabled, text is now wrapped in lightweight keyed spans
even once settled (they persist for reconciliation stability rather than being
torn down when streaming ends). Inline `code` now animates with the surrounding
text, while block code, images, math, and embeds reveal as a unit.
85 changes: 60 additions & 25 deletions apps/website/content/docs/animation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ related:
- /docs/memoization
---

Streamdown supports per-word streaming animation through the built-in `animated` prop. Words fade in as they mount, creating a smooth text-reveal effect during AI streaming. When streaming ends, the animation is removed entirely, leaving zero DOM overhead on completed messages.
Streamdown supports per-word streaming animation through the built-in `animated` prop. As content streams in, words are revealed in staggered chunks, creating a smooth text-reveal effect. A stateful controller drives the reveal, so the animation keeps pace with the stream and finishes draining any backlog even after the stream ends.

## Enabling animation

Expand All @@ -27,18 +27,23 @@ export default function Page() {
}
```

The `isAnimating` prop controls when the animation is active. When `false`, the animate plugin is excluded from the rehype pipeline entirely, so completed messages render as plain text with no extra `<span>` wrappers.
The `isAnimating` prop tells the controller a stream is in progress. While `true`, new content is revealed in staggered chunks. When it flips to `false`, the controller keeps draining whatever is still mid-reveal until everything is shown. Content that was never streamed (for example, historical messages mounted with `isAnimating` never `true`) is shown immediately with no animation.

## How it works

The animation is a rehype transformer that:
When `animated` is set, a rehype transformer splits each text node into per-segment `<span>` elements (words by default, see [`sep`](#options)) and assigns every segment a position in a global, document-ordered sequence. A controller tracks two frontiers over that sequence and classifies each segment into one of three zones:

1. Walks the HAST tree, visiting text nodes
2. Splits each text node into per-word `<span>` elements with `data-sd-animate`
3. Sets CSS custom properties for animation name, duration, and easing
4. Skips text inside `code`, `pre`, `svg`, `math`, and `annotation` elements
| Zone | Attribute | Behavior |
|------|-----------|----------|
| Settled | `data-sd-shown` | Already revealed and visible. |
| Active | `data-sd-animate` | The chunk currently animating in, with a staggered `--sd-delay`. |
| Pending | `data-sd-hidden` | Rendered but `display: none` until its turn. |

React's reconciliation ensures only newly-mounted spans trigger the CSS animation. Combined with a short default duration (150ms), this makes batch token arrivals look smooth rather than "chunky."
Container elements (list items, paragraphs) get a matching cascade so a parent with only pending children also collapses (`data-sd-appear` / `data-sd-hidden`).

Each segment span also carries a stable `data-sd-key` (derived from its source offset). Streamdown promotes that key to the React key and strips the attribute before it reaches the DOM. Because the key is stable across re-parses, already-shown words reconcile in place instead of remounting or re-running their animation when the surrounding markdown grows mid-stream.

Atomic elements — code blocks (`pre`), images, math/KaTeX, `svg`, `video`, and `iframe` — are revealed as a single unit rather than split into word spans, so their internal layout is never broken.

## Animation types

Expand Down Expand Up @@ -107,6 +112,19 @@ export default function Page() {
| `duration` | `number` | `150` | Animation duration in milliseconds. |
| `easing` | `string` | `"ease"` | CSS timing function. |
| `sep` | `"word" \| "char"` | `"word"` | Split text by word or character. |
| `reserveSpace` | `boolean` | `false` | Reserve layout space for unrevealed content instead of collapsing it. See [Layout: grow vs. reserve](#layout-grow-vs-reserve). |

### Layout: grow vs. reserve

By default, unrevealed content is collapsed with `display: none` and grows in as it reveals — the smoothest reveal, at the cost of layout shifting as content appears. Set `reserveSpace: true` to instead reserve the final layout up front and fade each segment's opacity in place, so the layout never shifts:

```tsx
<Streamdown animated={{ reserveSpace: true }} isAnimating={status === "streaming"}>
{markdown}
</Streamdown>
```

Use the default for the most polished reveal, or `reserveSpace` when stable layout matters more (for example, to avoid scroll jank during streaming).

### Character-level animation

Expand All @@ -122,15 +140,22 @@ This creates a typewriter-like effect but generates more DOM nodes. Use it spari

## Custom animations

Define your own `@keyframes` and reference them by name:
Define your own `@keyframes` and reference them by name. Flip `display` on at `1%` so the segment stays collapsed through its staggered (`backwards`-filled) delay and reserves no layout space until it animates in:

```css title="app/globals.css"
@keyframes sd-myCustomAnimation {
from {
0% {
display: none;
opacity: 0;
transform: scale(0.95);
}
1% {
display: revert;
opacity: 0;
transform: scale(0.95);
}
to {
100% {
display: revert;
opacity: 1;
transform: scale(1);
}
Expand Down Expand Up @@ -193,17 +218,17 @@ const animate = createAnimatePlugin({
// animate.rehypePlugin is a standard rehype plugin
```

## Skipped elements
## Atomic elements

The animation skips text inside these elements to avoid breaking their layout:
These elements are revealed as a single unit instead of being split into per-word spans, so their internal layout is never broken:

- `<code>` — inline and block code
- `<pre>` — preformatted text
- `<svg>` — vector graphics
- `<math>` — MathML elements
- `<annotation>` — MathML annotations
- `<pre>` — code blocks and syntax-highlighted code
- `<img>` — images
- `<svg>` — vector graphics and diagrams
- `<math>` (and `.katex` / `.math` containers) — math equations
- `<video>` and `<iframe>` — embedded media

This means code blocks, syntax-highlighted code, math equations, and diagrams render without animation spans.
Each animates in as a whole when its turn in the sequence arrives. Inline content (including inline `code`) is split and revealed word by word like the surrounding text.

## Fast-streaming models

Expand All @@ -230,25 +255,35 @@ For smoother results with fast models:

## CSS custom properties

Each animated span receives these CSS custom properties via inline styles:
Each active span receives these CSS custom properties via inline styles:

| Property | Description |
|----------|-------------|
| `--sd-animation` | The `@keyframes` name to use |
| `--sd-duration` | Animation duration |
| `--sd-easing` | CSS timing function |
| `--sd-delay` | Per-segment stagger delay |

The `[data-sd-animate]` selector in `styles.css` reads these properties:
`styles.css` defines the selectors for each zone:

```css
[data-sd-animate] {
animation: var(--sd-animation, sd-fadeIn)
var(--sd-duration, 150ms)
var(--sd-easing, ease) both;
animation: var(--sd-animation, sd-fadeIn) var(--sd-duration, 150ms)
var(--sd-easing, ease) var(--sd-delay, 0ms) backwards;
}

/* Containers reveal (display only) once their first child does. */
[data-sd-appear] {
animation: sd-appear 1ms step-end var(--sd-delay, 0ms) backwards;
}

/* Pending segments occupy no layout until their turn. */
[data-sd-hidden] {
display: none;
}
```

You can override these in your own CSS for more control.
The built-in keyframes flip `display` on at the start of the animation (the `0%`/`1%` split) so a segment stays collapsed through its `backwards`-filled delay window, then animates in. Settled segments carry `data-sd-shown` and have no animation. You can override any of these in your own CSS for more control.

## Related features

Expand Down
2 changes: 1 addition & 1 deletion apps/website/content/docs/plugins/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -137,4 +137,4 @@ The plugins are optimized for performance:
- Shiki languages are lazy-loaded only when needed
- Token results are cached to avoid re-highlighting
- KaTeX CSS is only loaded when math syntax is used
- Animation is excluded from the pipeline when `isAnimating` is `false`
- Animation segment counts are cached per block, so settled blocks aren't re-parsed each streaming tick
89 changes: 89 additions & 0 deletions packages/streamdown/__tests__/animate-controller.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import { AnimationController } from "../lib/animate/controller";

const cfg = (
over?: Partial<{ duration: number; stagger: number; coalesceCap: number }>
) => ({
duration: 150,
stagger: 40,
coalesceCap: 1000,
...over,
});

const INF = Number.POSITIVE_INFINITY;

describe("AnimationController", () => {
it("starts an active chunk over all available segments when idle", () => {
const c = new AnimationController(cfg());
c.update(3, INF, 0);
expect(c.plan).toEqual({ settledEnd: 0, activeEnd: 3 });
// 0 + stagger*(3-1) + duration
expect(c.nextWakeup).toBe(40 * 2 + 150);
expect(c.isAnimating).toBe(true);
});

it("holds new content as pending until the active chunk completes", () => {
const c = new AnimationController(cfg());
c.update(3, INF, 0);
c.update(5, INF, 10); // before completion
expect(c.plan).toEqual({ settledEnd: 0, activeEnd: 3 });
});

it("folds the active chunk into settled and starts the next on completion", () => {
const c = new AnimationController(cfg());
c.update(3, INF, 0);
const done = c.nextWakeup as number; // 230
c.update(5, INF, done);
expect(c.plan).toEqual({ settledEnd: 3, activeEnd: 5 });
expect(c.nextWakeup).toBe(done + 40 * 1 + 150);
});

it("drains to idle when fully caught up", () => {
const c = new AnimationController(cfg());
c.update(3, INF, 0);
const t1 = c.nextWakeup as number;
c.update(3, INF, t1);
const t2 = c.nextWakeup as number;
c.update(3, INF, t2);
expect(c.plan).toEqual({ settledEnd: 3, activeEnd: 3 });
expect(c.nextWakeup).toBeNull();
expect(c.isAnimating).toBe(false);
});

it("never animates past the gate", () => {
const c = new AnimationController(cfg());
c.update(5, 3, 0); // 5 total but gated at 3
expect(c.plan).toEqual({ settledEnd: 0, activeEnd: 3 });
const done = c.nextWakeup as number;
c.update(5, 5, done); // gate lifts
expect(c.plan).toEqual({ settledEnd: 3, activeEnd: 5 });
});

it("coalesces the backlog and snaps overflow beyond the cap to settled", () => {
const c = new AnimationController(cfg({ coalesceCap: 2 }));
c.update(10, INF, 0);
// oldest 8 snap to settled, last 2 animate
expect(c.plan).toEqual({ settledEnd: 8, activeEnd: 10 });
});

it("animates each segment promptly under a slow trickle", () => {
const c = new AnimationController(cfg());
c.update(1, INF, 0);
expect(c.plan).toEqual({ settledEnd: 0, activeEnd: 1 });
const done = c.nextWakeup as number; // 0 + 0 + 150
expect(done).toBe(150);
c.update(2, INF, done);
expect(c.plan).toEqual({ settledEnd: 1, activeEnd: 2 });
});

it("clamps frontiers when the segment count transiently shrinks", () => {
const c = new AnimationController(cfg());
c.update(5, INF, 0); // active [0,5]
const done = c.nextWakeup as number;
c.update(5, INF, done); // settled 5
c.update(2, INF, done + 1); // content shrank to 2
expect(c.plan.settledEnd).toBeLessThanOrEqual(2);
expect(c.plan.activeEnd).toBeLessThanOrEqual(2);
expect(c.plan.activeEnd).toBeGreaterThanOrEqual(c.plan.settledEnd);
});
});
46 changes: 46 additions & 0 deletions packages/streamdown/__tests__/animate-counter-options.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { renderHook } from "@testing-library/react";
import type { Root } from "hast";
import { describe, expect, it } from "vitest";
import { DEFAULT_ANIMATE_CONFIG } from "../lib/animate/transform";
import { useAnimation } from "../lib/animate/use-animation";
import type { Options } from "../lib/markdown";

// A rehype plugin that injects one extra word segment, so the same block string
// counts differently depending on whether it's in the pipeline.
const addSegment = () => (tree: Root) => {
tree.children.push({
type: "element",
tagName: "p",
properties: {},
children: [{ type: "text", value: "extra" }],
});
};

const baseOptions: Readonly<Omit<Options, "children">> = {
components: {},
remarkPlugins: [],
rehypePlugins: [],
};

describe("useAnimation segment counter", () => {
it("rebuilds the counter when countingOptions change mid-stream", () => {
const { result, rerender } = renderHook(
({ opts }: { opts: Readonly<Omit<Options, "children">> }) =>
useAnimation({
blocks: ["hello"],
config: DEFAULT_ANIMATE_CONFIG,
isAnimating: false,
countingOptions: opts,
}),
{ initialProps: { opts: baseOptions } }
);

// "hello" is a single word segment.
expect(result.current[0].plan.settledEnd).toBe(1);

// New options add a segment to the same block. A counter that only rebuilt
// on config changes would keep the stale cached count of 1.
rerender({ opts: { ...baseOptions, rehypePlugins: [addSegment] } });
expect(result.current[0].plan.settledEnd).toBe(2);
});
});
48 changes: 48 additions & 0 deletions packages/streamdown/__tests__/animate-reserve-space.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Streamdown } from "../index";

// `reserveSpace` is a pure root-level CSS toggle: it sets `--sd-hidden-display`
// so unrevealed segments fade opacity in place (stable layout) instead of
// collapsing with `display: none`. The transform/controller are unaffected.

beforeEach(() => {
vi.useFakeTimers();
vi.stubGlobal("performance", { now: () => 0 });
});

afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});

const rootOf = (container: HTMLElement): HTMLElement =>
container.firstElementChild as HTMLElement;

describe("animated reserveSpace option", () => {
it("sets --sd-hidden-display: revert on the root when enabled", () => {
const { container } = render(
<Streamdown
animated={{ reserveSpace: true }}
isAnimating
mode="streaming"
>
{"alpha beta gamma"}
</Streamdown>
);
expect(
rootOf(container).style.getPropertyValue("--sd-hidden-display")
).toBe("revert");
});

it("leaves the variable unset by default (collapse mode)", () => {
const { container } = render(
<Streamdown animated isAnimating mode="streaming">
{"alpha beta gamma"}
</Streamdown>
);
expect(
rootOf(container).style.getPropertyValue("--sd-hidden-display")
).toBe("");
});
});
Loading