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
20 changes: 20 additions & 0 deletions .changeset/smooth-streaming.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
"streamdown": patch
---

feat: add `smooth` prop to pace streams that arrive in large chunks

Some providers and relays deliver text in bursts, such as ~150 characters every ~500 ms, which renders as a jump followed by a pause. With `smooth`, Streamdown measures the time between arrivals and reveals each chunk a word at a time, so it finishes about when the next chunk is expected.

```tsx
<Streamdown smooth isAnimating={status === "streaming"}>
{markdown}
</Streamdown>
```

- Off by default. When off, rendering is unchanged.
- Only appends are paced. Resets, edits and full-text replacements render immediately, as does content present on mount.
- A word split across chunks is held until it's complete.
- With `animated`, `stagger` defaults to `0`, since pacing already spaces words out. An explicit `stagger` still applies.
- When `isAnimating` becomes `false`, the remaining text appears within 250 ms. Until then, caret, animation and incomplete-Markdown handling stay active, even if `mode` switches to `"static"` in the same update.
- Hidden tabs render immediately, since browsers pause animation frames there.
1 change: 1 addition & 0 deletions apps/website/content/docs/animation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,7 @@ You can override these in your own CSS for more control.

## Related features

- [Smooth streaming](/docs/smooth-streaming) — Pace streams that arrive in large chunks
- [Carets](/docs/carets) — Blinking cursor indicator during streaming
- [Plugins](/docs/plugins) — Overview of the plugin system
- [Configuration](/docs/configuration) — All Streamdown props including `isAnimating`
5 changes: 5 additions & 0 deletions apps/website/content/docs/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,11 @@ Math rendering and CJK support require installing separate plugins. See [Mathema
description: "Enable character-by-character animation for streaming content. See [Animation](/docs/animation).",
type: "boolean | AnimateOptions",
},
smooth: {
description: "Reveal text that arrives in large chunks a word at a time, at the rate it has been arriving. See [Smooth streaming](/docs/smooth-streaming).",
type: "boolean",
default: "false",
},
linkSafety: {
description: "Configure link safety modals for external URLs. See [Link Safety](/docs/link-safety).",
type: "LinkSafetyConfig",
Expand Down
45 changes: 45 additions & 0 deletions apps/website/content/docs/smooth-streaming.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
title: Smooth streaming
description: Reveal streams that arrive in large chunks a word at a time.
type: reference
summary: Paces text that arrives in large, infrequent chunks so it appears at a steady rate.
related:
- /docs/animation
- /docs/carets
---

Some providers and relays deliver text in large chunks, such as 150 characters every half second. Rendered as it arrives, the text jumps forward and then pauses, and the caret jumps with it. Set `smooth` to reveal each chunk a word at a time, at the rate text has been arriving.

## Enable smooth streaming

```tsx title="app/page.tsx"
import { Streamdown } from "streamdown";

export default function Page() {
return (
<Streamdown smooth isAnimating={status === "streaming"}>
{markdown}
</Streamdown>
);
}
```

`smooth` works with or without [`animated`](/docs/animation). With `animated`, each word animates as soon as it's revealed: `smooth` sets the default `stagger` to `0`, because pacing already spaces the words out. A `stagger` you set still applies.

## How it works

- Streamdown measures the time between chunks and reveals the text it has not shown yet so that it finishes about when the next chunk is expected.
- Text appears in whole words. While streaming, a word split across two chunks is held until it's complete.
- Only appended text is paced. Any other change renders immediately, such as a new message, an edit, or a model that resends the full text on every update.
- Content that exists when the component mounts renders immediately.
- Browsers pause animation frames in background tabs, so text that arrives there renders immediately.

## Latency

Pacing delays text by about one chunk interval: each chunk finishes appearing around when the next one arrives. For a stream that sends a chunk every 500 ms, that's about 500 ms. For a stream that sends tokens every few milliseconds, it's a frame or two.

## When the stream ends

Streamdown uses `isAnimating` to know when the stream ends. When it becomes `false`, the remaining text appears within 250 ms. Until then, Streamdown keeps its streaming behavior, including the caret, animations, and incomplete Markdown handling, even if you set `mode="static"` in the same update.

`onAnimationEnd` fires after the remaining text appears. As with other animation callbacks, it's suppressed in `mode="static"`.
209 changes: 209 additions & 0 deletions packages/streamdown/__tests__/smooth.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import { act, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { type BlockProps, Streamdown } from "../index";

const first = "Hello there";
const words = (count: number) =>
Array.from({ length: count }, (_, i) => `word${i}`).join(" ");
const full = `${first} ${words(40)}`;

const advance = (ms: number) => {
act(() => {
vi.advanceTimersByTime(ms);
});
};

const textOf = (container: HTMLElement) => container.textContent ?? "";

describe("smooth", () => {
beforeEach(() => {
vi.useFakeTimers({
toFake: [
"setTimeout",
"clearTimeout",
"requestAnimationFrame",
"cancelAnimationFrame",
"performance",
"Date",
],
});
});

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

it("renders appends immediately when off", () => {
const { container, rerender } = render(
<Streamdown isAnimating>{first}</Streamdown>
);
rerender(<Streamdown isAnimating>{full}</Streamdown>);
expect(textOf(container)).toBe(full);
});

it("shows existing content immediately on mount", () => {
const { container } = render(<Streamdown smooth>{full}</Streamdown>);
expect(textOf(container)).toBe(full);
});

it("reveals a large append over time, a word at a time", () => {
const { container, rerender } = render(
<Streamdown isAnimating smooth>
{first}
</Streamdown>
);
advance(500);
rerender(
<Streamdown isAnimating smooth>
{`${full} `}
</Streamdown>
);
expect(textOf(container)).toBe(first);

advance(48);
const partial = textOf(container);
expect(partial.length).toBeGreaterThan(first.length);
expect(partial.length).toBeLessThan(full.length);
expect(full.startsWith(partial)).toBe(true);
expect(full.charAt(partial.length)).toBe(" ");

advance(1000);
expect(textOf(container)).toBe(full);
});

it("keeps streaming behavior until caught up after switching to static", () => {
const { container, rerender } = render(
<Streamdown caret="block" isAnimating smooth>
{first}
</Streamdown>
);
advance(500);
// The stream ends in the same update that delivers a large final chunk,
// and the app switches to static mode.
rerender(
<Streamdown caret="block" isAnimating={false} mode="static" smooth>
{full}
</Streamdown>
);
const caret = () =>
(container.firstElementChild as HTMLElement).style.getPropertyValue(
"--streamdown-caret"
);
expect(textOf(container).length).toBeLessThan(full.length);
expect(caret()).toBe('" ▋"');

advance(300);
expect(textOf(container)).toBe(full);
expect(caret()).toBe("");
});

it("fires onAnimationEnd once the held-back text is shown", () => {
const onAnimationEnd = vi.fn();
const { container, rerender } = render(
<Streamdown isAnimating onAnimationEnd={onAnimationEnd} smooth>
{first}
</Streamdown>
);
advance(500);
rerender(
<Streamdown isAnimating={false} onAnimationEnd={onAnimationEnd} smooth>
{full}
</Streamdown>
);
expect(textOf(container).length).toBeLessThan(full.length);
expect(onAnimationEnd).not.toHaveBeenCalled();

advance(300);
expect(textOf(container)).toBe(full);
expect(onAnimationEnd).toHaveBeenCalledTimes(1);
});

it("reveals words without a stagger queue when animated", () => {
const maxDelay = (animated: boolean | { stagger: number }) => {
const { container, rerender, unmount } = render(
<Streamdown animated={animated} isAnimating smooth>
{first}
</Streamdown>
);
advance(500);
rerender(
<Streamdown animated={animated} isAnimating smooth>
{`${full} `}
</Streamdown>
);
advance(60);
const delays = Array.from(
container.querySelectorAll<HTMLElement>("[data-sd-animate]"),
(span) =>
Number.parseInt(span.style.getPropertyValue("--sd-delay") || "0", 10)
);
unmount();
return Math.max(0, ...delays);
};
expect(maxDelay(true)).toBe(0);
// An explicit stagger is still honored.
expect(maxDelay({ stagger: 40 })).toBeGreaterThan(0);
});

it("shows a change that is not an append immediately", () => {
const { container, rerender } = render(
<Streamdown isAnimating smooth>
{full}
</Streamdown>
);
rerender(
<Streamdown isAnimating smooth>
{"A different message entirely"}
</Streamdown>
);
expect(textOf(container)).toBe("A different message entirely");
});

it("shows everything at once in a hidden tab", () => {
const visibility = vi
.spyOn(document, "visibilityState", "get")
.mockReturnValue("hidden");
try {
const { container, rerender } = render(
<Streamdown isAnimating smooth>
{first}
</Streamdown>
);
advance(500);
rerender(
<Streamdown isAnimating smooth>
{`${full} `}
</Streamdown>
);
expect(textOf(container)).toBe(full);
} finally {
visibility.mockRestore();
}
});

it("does not rewind when turned on mid-stream", () => {
const rendered: string[] = [];
const RecordingBlock = ({ content }: BlockProps) => {
rendered.push(content);
return <p>{content}</p>;
};
const { rerender } = render(
<Streamdown BlockComponent={RecordingBlock} isAnimating>
{first}
</Streamdown>
);
rerender(
<Streamdown BlockComponent={RecordingBlock} isAnimating>
{full}
</Streamdown>
);
rendered.length = 0;
rerender(
<Streamdown BlockComponent={RecordingBlock} isAnimating smooth>
{full}
</Streamdown>
);
expect(rendered).not.toContain(first);
expect(rendered.at(-1)).toBe(full);
});
});
Loading
Loading