diff --git a/.changeset/smooth-streaming.md b/.changeset/smooth-streaming.md
new file mode 100644
index 00000000..701dd479
--- /dev/null
+++ b/.changeset/smooth-streaming.md
@@ -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
+
+ {markdown}
+
+```
+
+- 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.
diff --git a/apps/website/content/docs/animation.mdx b/apps/website/content/docs/animation.mdx
index 20a08a2e..895d80bd 100644
--- a/apps/website/content/docs/animation.mdx
+++ b/apps/website/content/docs/animation.mdx
@@ -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`
diff --git a/apps/website/content/docs/configuration.mdx b/apps/website/content/docs/configuration.mdx
index 07d60663..b4e1d9e9 100644
--- a/apps/website/content/docs/configuration.mdx
+++ b/apps/website/content/docs/configuration.mdx
@@ -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",
diff --git a/apps/website/content/docs/smooth-streaming.mdx b/apps/website/content/docs/smooth-streaming.mdx
new file mode 100644
index 00000000..2cb7e1ba
--- /dev/null
+++ b/apps/website/content/docs/smooth-streaming.mdx
@@ -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 (
+
+ {markdown}
+
+ );
+}
+```
+
+`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"`.
diff --git a/packages/streamdown/__tests__/smooth.test.tsx b/packages/streamdown/__tests__/smooth.test.tsx
new file mode 100644
index 00000000..28f88344
--- /dev/null
+++ b/packages/streamdown/__tests__/smooth.test.tsx
@@ -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(
+ {first}
+ );
+ rerender({full});
+ expect(textOf(container)).toBe(full);
+ });
+
+ it("shows existing content immediately on mount", () => {
+ const { container } = render({full});
+ expect(textOf(container)).toBe(full);
+ });
+
+ it("reveals a large append over time, a word at a time", () => {
+ const { container, rerender } = render(
+
+ {first}
+
+ );
+ advance(500);
+ rerender(
+
+ {`${full} `}
+
+ );
+ 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(
+
+ {first}
+
+ );
+ advance(500);
+ // The stream ends in the same update that delivers a large final chunk,
+ // and the app switches to static mode.
+ rerender(
+
+ {full}
+
+ );
+ 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(
+
+ {first}
+
+ );
+ advance(500);
+ rerender(
+
+ {full}
+
+ );
+ 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(
+
+ {first}
+
+ );
+ advance(500);
+ rerender(
+
+ {`${full} `}
+
+ );
+ advance(60);
+ const delays = Array.from(
+ container.querySelectorAll("[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(
+
+ {full}
+
+ );
+ rerender(
+
+ {"A different message entirely"}
+
+ );
+ 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(
+
+ {first}
+
+ );
+ advance(500);
+ rerender(
+
+ {`${full} `}
+
+ );
+ 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 {content}
;
+ };
+ const { rerender } = render(
+
+ {first}
+
+ );
+ rerender(
+
+ {full}
+
+ );
+ rendered.length = 0;
+ rerender(
+
+ {full}
+
+ );
+ expect(rendered).not.toContain(first);
+ expect(rendered.at(-1)).toBe(full);
+ });
+});
diff --git a/packages/streamdown/__tests__/text-pacer.test.ts b/packages/streamdown/__tests__/text-pacer.test.ts
new file mode 100644
index 00000000..ed0da9ac
--- /dev/null
+++ b/packages/streamdown/__tests__/text-pacer.test.ts
@@ -0,0 +1,134 @@
+import { describe, expect, it } from "vitest";
+import { createTextPacer, snapToWord } from "../lib/use-smooth-stream";
+
+const WHITESPACE_RE = /\s/;
+
+describe("snapToWord", () => {
+ it("extends to the end of the word the head is in", () => {
+ expect(snapToWord("hello world", 2, true)).toBe(5);
+ });
+
+ it("holds back a trailing partial word while streaming", () => {
+ expect(snapToWord("hello wor", 9, false)).toBe(6);
+ });
+
+ it("shows the trailing word once the stream is final", () => {
+ expect(snapToWord("hello wor", 9, true)).toBe(9);
+ });
+
+ it("treats each CJK character as its own unit", () => {
+ expect(snapToWord("你好世界", 2, false)).toBe(2);
+ expect(snapToWord("你好世界", 4, false)).toBe(4);
+ });
+
+ it("caps runs with no boundary", () => {
+ expect(snapToWord("a".repeat(100), 0, true)).toBe(32);
+ expect(snapToWord("a".repeat(100), 100, false)).toBe(68);
+ });
+
+ it("does not split a surrogate pair at a cap", () => {
+ const text = `${"a".repeat(31)}😀${"a".repeat(20)}`;
+ expect(snapToWord(text, 0, true)).toBe(31);
+ });
+});
+
+describe("createTextPacer", () => {
+ it("treats the initial text as already shown", () => {
+ const pacer = createTextPacer("already here");
+ expect(pacer.visible).toBe("already here");
+ expect(pacer.isRevealing).toBe(false);
+ });
+
+ it("reveals an append gradually over the measured gap", () => {
+ const pacer = createTextPacer("");
+ const first = "one two ";
+ const second = `${first}three four five six seven eight nine ten `;
+
+ pacer.update(first, true, 0);
+ pacer.advance(200);
+ expect(pacer.visible).toBe(first);
+
+ pacer.update(second, true, 500);
+ expect(pacer.visible).toBe(first);
+ expect(pacer.isRevealing).toBe(true);
+
+ pacer.advance(750);
+ expect(pacer.visible.length).toBeGreaterThan(first.length);
+ expect(pacer.visible.length).toBeLessThan(second.length);
+ expect(second.startsWith(pacer.visible)).toBe(true);
+ expect(second.charAt(pacer.visible.length)).toMatch(WHITESPACE_RE);
+
+ pacer.advance(1000);
+ expect(pacer.visible).toBe(second);
+ expect(pacer.isRevealing).toBe(false);
+ });
+
+ it("never shows a partial trailing word while streaming", () => {
+ const pacer = createTextPacer("");
+ pacer.update("playing gu", true, 0);
+ pacer.advance(10_000);
+ expect(pacer.visible).toBe("playing ");
+ expect(pacer.isRevealing).toBe(false);
+
+ pacer.update("playing guitar ", true, 10_500);
+ pacer.advance(20_000);
+ expect(pacer.visible).toBe("playing guitar ");
+ });
+
+ it("shows everything shortly after the stream ends", () => {
+ const pacer = createTextPacer("");
+ pacer.update("a b ", true, 0);
+ const text = `a b ${"word ".repeat(60)}tail`;
+ pacer.update(text, true, 500);
+ pacer.advance(520);
+ expect(pacer.visible.length).toBeLessThan(text.length);
+
+ pacer.update(text, false, 520);
+ pacer.advance(520 + 250);
+ expect(pacer.visible).toBe(text);
+ expect(pacer.isRevealing).toBe(false);
+ });
+
+ it("shows a change that is not an append immediately", () => {
+ const pacer = createTextPacer("");
+ pacer.update("first draft of the text ", true, 0);
+ pacer.update("a replacement frame", true, 100);
+ expect(pacer.visible).toBe("a replacement frame");
+ expect(pacer.isRevealing).toBe(false);
+ });
+
+ it("shows a same-length replacement immediately", () => {
+ const pacer = createTextPacer("abc");
+ pacer.update("xyz", true, 0);
+ expect(pacer.visible).toBe("xyz");
+ });
+
+ it("flush shows everything", () => {
+ const pacer = createTextPacer("");
+ pacer.update("x ", true, 0);
+ pacer.update(`x ${"long chunk ".repeat(20)}`, true, 500);
+ pacer.flush();
+ expect(pacer.visible).toBe(`x ${"long chunk ".repeat(20)}`);
+ });
+
+ it("keeps up with a steady cadence instead of building a backlog", () => {
+ const pacer = createTextPacer("");
+ const chunk = "alpha beta gamma delta epsilon zeta eta theta iota kappa ";
+ let text = "";
+ for (let i = 0; i < 20; i += 1) {
+ const at = i * 500;
+ text += chunk;
+ pacer.update(text, true, at);
+ // Just before the next chunk, at most the tail of this one is left.
+ pacer.advance(at + 499);
+ if (i >= 3) {
+ expect(text.length - pacer.visible.length).toBeLessThan(
+ chunk.length / 2
+ );
+ }
+ // Whole words only: fully shown, or cut right before whitespace.
+ const next = text.charAt(pacer.visible.length);
+ expect(next === "" || WHITESPACE_RE.test(next)).toBe(true);
+ }
+ });
+});
diff --git a/packages/streamdown/index.tsx b/packages/streamdown/index.tsx
index dce573c4..a1ab923b 100644
--- a/packages/streamdown/index.tsx
+++ b/packages/streamdown/index.tsx
@@ -59,6 +59,7 @@ import {
TranslationsContext,
} from "./lib/translations-context";
import { useCaretHost } from "./lib/use-caret-host";
+import { useSmoothStream } from "./lib/use-smooth-stream";
import { createCn } from "./lib/utils";
export type { AnimateOptions } from "./lib/animate";
@@ -292,6 +293,16 @@ export type StreamdownProps = Options & {
*/
tableMaxHeight?: number | string;
animated?: boolean | AnimateOptions;
+ /**
+ * Pace bursty streams. Text that arrives in large chunks is revealed a word
+ * at a time at the rate it has been arriving, instead of all at once.
+ * Adds roughly one chunk interval of display latency. Uses `isAnimating`
+ * to know when the stream ends. Until the held-back text is shown,
+ * Streamdown stays in streaming mode (caret, animation, incomplete-Markdown
+ * handling) even if `mode` is `"static"`, and `onAnimationEnd` waits for
+ * it. @default false
+ */
+ smooth?: boolean;
caret?: keyof typeof carets;
plugins?: PluginConfig;
remend?: RemendOptions;
@@ -467,6 +478,30 @@ export const StreamdownContext = createContext(
defaultStreamdownContext
);
+const getAnimatedKey = (
+ animated: StreamdownProps["animated"],
+ smooth: boolean
+): string => {
+ if (!animated) {
+ return "";
+ }
+ const options = animated === true ? "true" : JSON.stringify(animated);
+ // Block plugins bake in a stagger default that depends on smooth.
+ return smooth ? `${options}:smooth` : options;
+};
+
+/** Options for one block's animate plugin; maxBacklogMs goes to the timeline. */
+const getBlockAnimateOptions = (
+ animated: StreamdownProps["animated"],
+ smooth: boolean
+): AnimateOptions => {
+ const { maxBacklogMs: _, ...options }: AnimateOptions =
+ typeof animated === "object" ? animated : {};
+ // Smooth already spaces words out; a stagger would queue them a second
+ // time behind the reveal.
+ return { ...options, stagger: options.stagger ?? (smooth ? 0 : undefined) };
+};
+
export type BlockProps = Options & {
content: string;
shouldParseIncompleteMarkdown: boolean;
@@ -592,7 +627,7 @@ Block.displayName = "Block";
export const Streamdown = memo(
({
children,
- mode = "streaming",
+ mode: modeProp = "streaming",
dir,
parseIncompleteMarkdown: shouldParseIncompleteMarkdown = true,
normalizeHtmlIndentation: shouldNormalizeHtmlIndentation = false,
@@ -604,9 +639,10 @@ export const Streamdown = memo(
mermaid,
codeBlockMaxHeight = 400,
controls = true,
- isAnimating = false,
+ isAnimating: isAnimatingProp = false,
tableMaxHeight = 300,
animated,
+ smooth = false,
BlockComponent = Block,
parseMarkdownIntoBlocksFn = parseMarkdownIntoBlocks,
caret,
@@ -629,6 +665,17 @@ export const Streamdown = memo(
// All hooks must be called before any conditional returns
const generatedId = useId();
+ const {
+ text: smoothed,
+ isAnimating,
+ mode,
+ } = useSmoothStream({
+ children,
+ isAnimating: isAnimatingProp,
+ mode: modeProp,
+ smooth,
+ });
+
const prefixedCn = useMemo(() => createCn(prefix), [prefix]);
// null means "first render" — distinguishes from false so we can fire
@@ -678,8 +725,8 @@ export const Streamdown = memo(
}
let result =
mode === "streaming" && shouldParseIncompleteMarkdown
- ? remend(children, remendOptions)
- : children;
+ ? remend(smoothed, remendOptions)
+ : smoothed;
// Escape markdown metacharacters inside literal-tag-content tags so that
// children are rendered as plain text rather than parsed as markdown.
@@ -699,6 +746,7 @@ export const Streamdown = memo(
return result;
}, [
children,
+ smoothed,
mode,
shouldParseIncompleteMarkdown,
remendOptions,
@@ -737,15 +785,10 @@ export const Streamdown = memo(
// plugin from being recreated when the user passes an inline object
// literal (e.g. animated={{ animation: 'fadeIn' }}) whose reference
// changes on every parent render.
- const animatedKey = useMemo(() => {
- if (animated === true) {
- return "true";
- }
- if (animated) {
- return JSON.stringify(animated);
- }
- return "";
- }, [animated]);
+ const animatedKey = useMemo(
+ () => getAnimatedKey(animated, smooth),
+ [animated, smooth]
+ );
// Shared wall-clock timeline: serializes stagger delays across sibling
// blocks AND across streaming ticks (memoized earlier blocks don't
@@ -763,9 +806,7 @@ export const Streamdown = memo(
if (prevAnimatedKeyRef.current !== animatedKey) {
prevAnimatedKeyRef.current = animatedKey;
const backlog =
- animatedKey !== "true"
- ? (animated as AnimateOptions).maxBacklogMs
- : undefined;
+ typeof animated === "object" ? animated.maxBacklogMs : undefined;
animateTimelineRef.current = createAnimateTimeline({
maxBacklogMs: backlog,
});
@@ -1026,13 +1067,8 @@ export const Streamdown = memo(
if (animateTimelineRef.current && isAnimating) {
if (!blockAnimatePluginsRef.current[index]) {
// maxBacklogMs is consumed by the timeline factory, not the plugin.
- const rawOpts =
- animatedKey && animatedKey !== "true"
- ? (animated as AnimateOptions)
- : ({} as AnimateOptions);
- const { maxBacklogMs: _, ...pluginOpts } = rawOpts;
blockAnimatePluginsRef.current[index] = createRenderAnimatePlugin({
- ...pluginOpts,
+ ...getBlockAnimateOptions(animated, smooth),
timeline: animateTimelineRef.current,
});
}
@@ -1159,6 +1195,7 @@ export const Streamdown = memo(
prevProps.shikiTheme === nextProps.shikiTheme &&
prevProps.isAnimating === nextProps.isAnimating &&
prevProps.animated === nextProps.animated &&
+ prevProps.smooth === nextProps.smooth &&
prevProps.mode === nextProps.mode &&
prevProps.plugins === nextProps.plugins &&
prevProps.className === nextProps.className &&
diff --git a/packages/streamdown/lib/use-smooth-stream.ts b/packages/streamdown/lib/use-smooth-stream.ts
new file mode 100644
index 00000000..dde1d26c
--- /dev/null
+++ b/packages/streamdown/lib/use-smooth-stream.ts
@@ -0,0 +1,249 @@
+import { useEffect, useRef, useState } from "react";
+
+/** Reveal horizon before a second chunk gives us a real gap to measure. */
+const FIRST_GAP_MS = 120;
+/** Clamp for the gap estimate, so a long stall doesn't make the next reveal crawl. */
+const MIN_GAP_MS = 16;
+const MAX_GAP_MS = 1000;
+/**
+ * Gap estimate smoothing. It rises fast when chunks slow down and decays
+ * slowly when they speed up, so one quick chunk can't drain the buffer early
+ * before the next slow gap.
+ */
+const GAP_RISE = 0.5;
+const GAP_DECAY = 0.15;
+/** Once the stream ends, show whatever is still held back within this time. */
+const DRAIN_MS = 250;
+/** Longest run revealed or held back as one "word" when no boundary is found. */
+const MAX_WORD_CHARS = 32;
+
+// Whitespace ends a word. Han, kana and Hangul characters are each their own
+// unit, so CJK text still reveals smoothly without spaces.
+const BOUNDARY_RE =
+ /[\s\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
+
+const isBoundary = (char: string): boolean => BOUNDARY_RE.test(char);
+
+const isHighSurrogate = (code: number): boolean =>
+ code >= 0xd8_00 && code <= 0xdb_ff;
+
+/**
+ * Visible length for a reveal head at `head`. Extends forward to the end of
+ * the word the head is in, so words appear whole. While streaming, holds
+ * back a trailing word the producer may still be extending.
+ */
+export const snapToWord = (
+ text: string,
+ head: number,
+ final: boolean
+): number => {
+ let end = Math.min(Math.floor(head), text.length);
+ const limit = Math.min(text.length, end + MAX_WORD_CHARS);
+ while (end < limit && !isBoundary(text.charAt(end))) {
+ end += 1;
+ }
+ if (!final && end === text.length) {
+ const floor = Math.max(0, end - MAX_WORD_CHARS);
+ while (end > floor && !isBoundary(text.charAt(end - 1))) {
+ end -= 1;
+ }
+ }
+ // Never split a surrogate pair at a MAX_WORD_CHARS cut.
+ if (
+ end > 0 &&
+ end < text.length &&
+ isHighSurrogate(text.charCodeAt(end - 1))
+ ) {
+ end -= 1;
+ }
+ return end;
+};
+
+export interface TextPacer {
+ /** Move the reveal head forward to `now`. */
+ advance: (now: number) => void;
+ /** Show everything immediately. */
+ flush: () => void;
+ /** True while there is text the pacer can still reveal. */
+ readonly isRevealing: boolean;
+ /**
+ * Feed the latest full text. Appends are paced; any other change (reset,
+ * edit, a diffusion model's full-text frame) is shown immediately.
+ */
+ update: (text: string, streaming: boolean, now: number) => void;
+ readonly visible: string;
+}
+
+/**
+ * Jitter buffer for bursty streams. Each arrival re-targets the reveal rate
+ * so the current backlog finishes around when the next chunk is expected.
+ * `initial` is treated as already shown.
+ */
+export const createTextPacer = (initial: string): TextPacer => {
+ let target = initial;
+ /** Fractional reveal position. */
+ let head = initial.length;
+ /** Visible length, snapped to words. Never decreases for a given target. */
+ let end = initial.length;
+ /** Characters per millisecond. */
+ let rate = 0;
+ /** Estimated milliseconds between arrivals; 0 until measured. */
+ let gap = 0;
+ let lastArrival: number | null = null;
+ let lastAdvance: number | null = null;
+ let streaming = false;
+ /** `target.slice(0, end)`, cached so frames that reveal nothing don't copy. */
+ let visible = initial;
+
+ const growTo = (next: number) => {
+ if (next > end) {
+ end = next;
+ visible = target.slice(0, end);
+ }
+ };
+
+ const advance = (now: number) => {
+ const elapsed = lastAdvance === null ? 0 : Math.max(0, now - lastAdvance);
+ lastAdvance = now;
+ head = Math.min(target.length, head + rate * elapsed);
+ growTo(snapToWord(target, head, !streaming));
+ };
+
+ const recordArrival = (now: number) => {
+ if (lastArrival !== null) {
+ const observed = Math.min(
+ MAX_GAP_MS,
+ Math.max(MIN_GAP_MS, now - lastArrival)
+ );
+ const weight = observed > gap ? GAP_RISE : GAP_DECAY;
+ gap = gap === 0 ? observed : gap + weight * (observed - gap);
+ }
+ lastArrival = now;
+ };
+
+ return {
+ advance,
+ flush() {
+ head = target.length;
+ rate = 0;
+ growTo(target.length);
+ },
+ get isRevealing() {
+ return end < snapToWord(target, target.length, !streaming);
+ },
+ update(text, isStreaming, now) {
+ // Settle progress made under the previous rate first.
+ advance(now);
+ streaming = isStreaming;
+
+ if (text !== target) {
+ if (text.startsWith(target)) {
+ recordArrival(now);
+ target = text;
+ } else {
+ target = text;
+ head = text.length;
+ end = text.length;
+ visible = text;
+ gap = 0;
+ lastArrival = null;
+ }
+ }
+
+ const backlog = target.length - head;
+ rate = backlog / (gap || FIRST_GAP_MS);
+ if (!streaming) {
+ rate = Math.max(rate, backlog / DRAIN_MS);
+ }
+ },
+ get visible() {
+ return visible;
+ },
+ };
+};
+
+const now = (): number =>
+ typeof performance === "undefined" ? Date.now() : performance.now();
+
+const isDocumentHidden = (): boolean =>
+ typeof document !== "undefined" && document.visibilityState === "hidden";
+
+export interface SmoothStreamOptions {
+ children: unknown;
+ /** Whether more text may still arrive. */
+ isAnimating: boolean;
+ mode: "static" | "streaming";
+ smooth: boolean;
+}
+
+export interface SmoothStream {
+ isAnimating: boolean;
+ mode: "static" | "streaming";
+ text: string;
+}
+
+/**
+ * Paces string `children` for display when `smooth` is on. Appends are
+ * revealed over time; any other change renders as-is. While held-back text
+ * is still being shown, reports streaming state even if the stream has
+ * ended, so caret, animation and `onAnimationEnd` wait for it.
+ */
+export const useSmoothStream = ({
+ children,
+ isAnimating,
+ mode,
+ smooth,
+}: SmoothStreamOptions): SmoothStream => {
+ const text = typeof children === "string" ? children : "";
+ const pacerRef = useRef(null);
+ const [visible, setVisible] = useState(text);
+ const [wasSmooth, setWasSmooth] = useState(smooth);
+
+ // Turning pacing on mid-stream starts from the full current text, not from
+ // whatever was last paced.
+ if (smooth !== wasSmooth) {
+ setWasSmooth(smooth);
+ setVisible(text);
+ }
+
+ // Constant while disabled, so the effect doesn't re-run on every token.
+ const source = smooth ? text : "";
+ const streaming = smooth && isAnimating;
+
+ useEffect(() => {
+ if (!smooth) {
+ pacerRef.current = null;
+ return;
+ }
+ pacerRef.current ??= createTextPacer(source);
+ const pacer = pacerRef.current;
+ pacer.update(source, streaming, now());
+ // Animation frames don't run in hidden tabs. Arrivals and the end of the
+ // stream still land here, so show everything instead of stalling. If the
+ // tab hides between arrivals, the first frame back catches up at once.
+ if (isDocumentHidden()) {
+ pacer.flush();
+ }
+
+ let frame = 0;
+ const tick = () => {
+ pacer.advance(now());
+ setVisible(pacer.visible);
+ if (pacer.isRevealing) {
+ frame = requestAnimationFrame(tick);
+ }
+ };
+ tick();
+ return () => cancelAnimationFrame(frame);
+ }, [source, streaming, smooth]);
+
+ // Until the effect catches up, a non-append change renders as-is rather
+ // than flashing the previous text.
+ const shown = smooth && text.startsWith(visible) ? visible : text;
+ const isCatchingUp = shown.length < text.length;
+ return {
+ isAnimating: isAnimating || isCatchingUp,
+ mode: isCatchingUp ? "streaming" : mode,
+ text: shown,
+ };
+};