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

feat: accept animation effect objects in `animated.animation`

An `AnimationEffect` bundles a keyframes name with the per-word behavior those keyframes need:

```ts
interface AnimationEffect {
name: string; // @keyframes sd-<name>
duration?: number; // default; animated.duration overrides it
scatter?: number; // max extra delay per word, hashed from its position
decorate?: (text: string, seed: number) => Record<`data-${string}`, string>;
}
```

`scatter` reveals words in a stable random order instead of left to right. `decorate` adds `data-*` attributes to each word while it animates, for effect styles to use. Other attributes are dropped. String animation names work as before.
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.
16 changes: 16 additions & 0 deletions .changeset/streamdown-effects.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@streamdown/effects": patch
---

feat: add `@streamdown/effects` with `diffuse` and `scramble`

Diffusion-style streaming effects for `animated.animation`. `diffuse` sharpens words out of a blur in scattered order. `scramble` shows noise glyphs that resolve to the real text, keeping the real words in the DOM for copy and assistive technology.

```tsx
import { scramble } from "@streamdown/effects";
import "@streamdown/effects/styles.css";

<Streamdown animated={{ animation: scramble }} isAnimating={isStreaming} smooth>
{markdown}
</Streamdown>
```
15 changes: 15 additions & 0 deletions .changeset/tidy-words-fade.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"streamdown": patch
---

Preserve pending and active word animations across streaming updates instead of setting their duration to zero before they finish. Apply the same timing to list markers, task checkboxes, images, and horizontal rules.

Allow finite text animations to finish after streaming stops before removing their wrappers. Cleanup follows browser animation completion, including cancellation, rather than using a fixed timeout.

Discard animation history for removed blocks and reset the timeline when content is cleared, so the first words of a replay animate again instead of appearing instantly.

Exclude parser-generated layout whitespace from animation offsets so new words inside lists receive their fade and tight-to-loose list transitions preserve existing text history. Source whitespace remains counted.

Animate fenced code as one unit on the shared timeline, preserving its fade while code and syntax highlighting update.

Use one timestamp per render pass when scheduling blocks, so parsing time cannot let later blocks overtake earlier text.
24 changes: 23 additions & 1 deletion apps/website/content/docs/animation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ export default function Page() {

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `animation` | `string` | `"fadeIn"` | Animation name. Built-in: `fadeIn`, `blurIn`, `slideUp`. Custom strings are prefixed with `sd-`. |
| `animation` | `string \| AnimationEffect` | `"fadeIn"` | Animation name. Built-in: `fadeIn`, `blurIn`, `slideUp`. Custom strings are prefixed with `sd-`. Also accepts an [effect](#animation-effects). |
| `duration` | `number` | `150` | Animation duration in milliseconds. |
| `easing` | `string` | `"ease"` | CSS timing function. |
| `sep` | `"word" \| "char"` | `"word"` | Split text by word or character. |
Expand Down Expand Up @@ -145,6 +145,27 @@ Define your own `@keyframes` and reference them by name:

The animation name is automatically prefixed with `sd-`, so define your keyframes as `sd-yourName`.

## Animation effects

`animation` also accepts an effect object. An effect bundles a keyframes name with the per-word behavior those keyframes need, such as a scattered reveal order or attributes for your CSS to draw. [`@streamdown/effects`](/docs/plugins/effects) provides ready-made effects.

```tsx
import type { AnimationEffect } from "streamdown";

const shimmer: AnimationEffect = {
name: "shimmer", // uses @keyframes sd-shimmer
duration: 300, // default; animated.duration overrides it
scatter: 120, // up to 120 ms extra delay per word, in a stable random order
decorate: (text, seed) => ({ "data-shimmer": text }), // extra data-* attributes
};

<Streamdown animated={{ animation: shimmer }} isAnimating={status === "streaming"}>
{markdown}
</Streamdown>;
```

`decorate` runs for each word while it animates. Return the same attributes for the same `text` and `seed`, because words re-render while they animate. Only `data-*` attributes are applied.

## Callbacks

Use `onAnimationStart` and `onAnimationEnd` to react to animation state changes. These fire when `isAnimating` transitions from `false` to `true` and vice versa. Both callbacks are suppressed in `mode="static"`.
Expand Down Expand Up @@ -253,6 +274,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
59 changes: 59 additions & 0 deletions apps/website/content/docs/plugins/effects.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
---
title: "@streamdown/effects"
description: Streaming text effects for the animated prop.
type: reference
summary: Diffusion-style effects that resolve words in scattered order, from a blur or from noise glyphs.
prerequisites:
- /docs/animation
related:
- /docs/smooth-streaming
---

The `@streamdown/effects` package provides [animation effects](/docs/animation#animation-effects) that reveal streaming text in scattered order, like a diffusion model resolving its output.

- `diffuse`: words sharpen out of a blur.
- `scramble`: words start as noise glyphs that change once, then resolve to the real text.

## Install

```package-install
npm install @streamdown/effects
```

## Usage

Import the effect and its styles, and pass the effect as `animated.animation`:

```tsx title="app/page.tsx"
import { Streamdown } from "streamdown";
import { scramble } from "@streamdown/effects";
import "streamdown/styles.css";
import "@streamdown/effects/styles.css";

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

Effects look best with [`smooth`](/docs/smooth-streaming), which reveals text a few words at a time. Without it, a large chunk resolves all at once.

## Options

`animated.duration`, `animated.easing`, and `animated.sep` work as they do for built-in animations. `sep: "char"` scrambles each character on its own.

| Effect | Default duration | Scatter |
|--------|------------------|---------|
| `diffuse` | 420 ms | up to 140 ms |
| `scramble` | 320 ms | up to 160 ms |

## Accessibility

`scramble` keeps the real text in the DOM. It paints the text transparent and draws the noise with CSS pseudo-elements that have empty alternative text, so selection, copy, and assistive technology get the real words. With `prefers-reduced-motion: reduce`, `scramble` falls back to a fade without glyphs.
2 changes: 1 addition & 1 deletion apps/website/content/docs/plugins/meta.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"title": "Plugins",
"pages": ["index", "code", "mermaid", "math", "cjk"]
"pages": ["index", "code", "mermaid", "math", "cjk", "effects"]
}
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"`.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"bench": "turbo run bench",
"bench:ui": "turbo run bench:ui",
"build": "turbo run build",
"build:packages": "pnpm build --filter streamdown --filter @streamdown/math --filter @streamdown/code --filter @streamdown/cjk --filter @streamdown/mermaid",
"build:packages": "pnpm build --filter streamdown --filter @streamdown/math --filter @streamdown/code --filter @streamdown/cjk --filter @streamdown/mermaid --filter @streamdown/effects",
"bump-deps": "npx npm-check-updates -u --deep -x \"recharts\" && pnpm i",
"changeset": "changeset",
"changeset:publish": "pnpm build && changeset publish",
Expand Down
50 changes: 50 additions & 0 deletions packages/streamdown-effects/__tests__/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { diffuse, noise, scramble } from "../index";

const UPPER_RE = /\p{Lu}/u;
const LOWER_RE = /\p{Ll}/u;
const DIGIT_RE = /\p{N}/u;

describe("noise", () => {
it("keeps the shape of the text", () => {
const text = "Rivers, 42 times—über!";
const out = noise(text, 7);
expect([...out]).toHaveLength([...text].length);
[...text].forEach((real, i) => {
const fake = [...out][i];
if (UPPER_RE.test(real)) {
expect(fake).toMatch(UPPER_RE);
} else if (LOWER_RE.test(real)) {
expect(fake).toMatch(LOWER_RE);
} else if (DIGIT_RE.test(real)) {
expect(fake).toMatch(DIGIT_RE);
} else {
expect(fake).toBe(real);
}
});
});

it("is deterministic per seed", () => {
expect(noise("streaming", 3)).toBe(noise("streaming", 3));
expect(noise("streaming", 3)).not.toBe(noise("streaming", 4));
});
});

describe("effects", () => {
it("diffuse scatters a blur without decorating", () => {
expect(diffuse).toEqual({ duration: 420, name: "diffuse", scatter: 140 });
});

it("scramble stamps two different noise sets", () => {
const attributes = scramble.decorate?.("Streaming", 11) ?? {};
expect(Object.keys(attributes)).toEqual([
"data-sd-glyphs",
"data-sd-glyphs-alt",
]);
expect(attributes["data-sd-glyphs"]).toHaveLength(9);
expect(attributes["data-sd-glyphs"]).not.toBe(
attributes["data-sd-glyphs-alt"]
);
expect(scramble.decorate?.("Streaming", 11)).toEqual(attributes);
});
});
70 changes: 70 additions & 0 deletions packages/streamdown-effects/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* Matches `AnimationEffect` in streamdown. Declared here so this package has
* no runtime or type dependency on it.
*/
export interface AnimationEffect {
decorate?: (text: string, seed: number) => Record<`data-${string}`, string>;
duration?: number;
name: string;
scatter?: number;
}

const UPPERCASE_RE = /\p{Lu}/u;
const LETTER_RE = /\p{L}/u;
const NUMBER_RE = /\p{N}/u;

const LOWER = "abcdefghijklmnopqrstuvwxyz";
const UPPER = LOWER.toUpperCase();
const DIGITS = "0123456789";

const hash01 = (a: number, b: number): number => {
const x = Math.sin(a * 12.9898 + b * 78.233) * 43_758.5453;
return x - Math.floor(x);
};

const pick = (set: string, seed: number, index: number): string =>
set.charAt(Math.floor(hash01(seed, index) * set.length));

/**
* Same-shape noise for a word: letters and digits become random characters
* of the same class; punctuation and spacing are kept. Deterministic for a
* given seed.
*/
export const noise = (text: string, seed: number): string => {
let out = "";
let index = 0;
for (const char of text) {
if (UPPERCASE_RE.test(char)) {
out += pick(UPPER, seed, index);
} else if (LETTER_RE.test(char)) {
out += pick(LOWER, seed, index);
} else if (NUMBER_RE.test(char)) {
out += pick(DIGITS, seed, index);
} else {
out += char;
}
index += 1;
}
return out;
};

/** Words sharpen out of a blur, in scattered order. */
export const diffuse: AnimationEffect = {
duration: 420,
name: "diffuse",
scatter: 140,
};

/**
* Words start as noise glyphs that change once, then resolve to the real
* text, in scattered order. The real text stays in the DOM throughout.
*/
export const scramble: AnimationEffect = {
decorate: (text, seed) => ({
"data-sd-glyphs": noise(text, seed),
"data-sd-glyphs-alt": noise(text, seed + 1),
}),
duration: 320,
name: "scramble",
scatter: 160,
};
Loading
Loading