Skip to content
Merged
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
9 changes: 9 additions & 0 deletions .changeset/animate-inline-code.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"streamdown": patch
---

fix(animate): animate inline code during streaming

Skip the animate visitor on `pre` (and svg/math/annotation) only — not bare `code`. Fenced/highlighted blocks stay un-split via their `pre` ancestor; inline backtick spans now get the same per-word fade-in as surrounding prose.

Fixes #594
9 changes: 5 additions & 4 deletions apps/website/content/docs/animation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ The animation is a rehype transformer that:
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
4. Skips text inside `pre`, `svg`, `math`, and `annotation` elements (inline `code` is animated with surrounding prose)

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."

Expand Down Expand Up @@ -197,13 +197,14 @@ const animate = createAnimatePlugin({

The animation skips text inside these elements to avoid breaking their layout:

- `<code>` — inline and block code
- `<pre>` — preformatted text
- `<pre>` — preformatted / fenced code blocks (CommonMark always emits `pre > code`, so Shiki-highlighted blocks stay un-split)
- `<svg>` — vector graphics
- `<math>` — MathML elements
- `<annotation>` — MathML annotations

This means code blocks, syntax-highlighted code, math equations, and diagrams render without animation spans.
Inline `<code>` (backtick spans) **is** animated — wrapping words in style-inheriting spans is layout-neutral, same as surrounding paragraphs.

This means fenced code blocks, syntax-highlighted code, math equations, and diagrams render without animation spans, while inline code fades in with the prose.

## Fast-streaming models

Expand Down
37 changes: 27 additions & 10 deletions packages/streamdown/__tests__/animate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ const SPAN_GAP_CHAR_RE = /<\/span> <span[^>]*>t</;
const HELLO_SPAN_RE = />Hello <\/span>/;
const WORLD_SPAN_RE = />world<\/span>/;
const I_SPACE_SPAN_RE = />i <\/span>/;
const CODE_CONTENT_RE = /<code>([^<]*)<\/code>/;
const INLINE_CODE_ANIMATE_RE =
/<code[^>]*>[\s\S]*data-sd-animate[\s\S]*world[\s\S]*<\/code>/;
const FENCED_PRE_BARE_RE = /<pre><code>block<\/code><\/pre>/;
const PRE_ANIMATE_RE = /<pre>[\s\S]*data-sd-animate/;

const INPUT_TAG_RE = /<input[^>]*>/;
const INPUT_TAG_GLOBAL_RE = /<input[^>]*>/g;
const IMG_TAG_RE = /<img[^>]*>/;
Expand Down Expand Up @@ -114,29 +118,42 @@ describe("animate plugin", () => {
});

describe("skip tags", () => {
it("should not animate text inside code elements", async () => {
// Inline <code> is layout-neutral to word-span wrapping (#594).
it("should animate text inside inline code elements", async () => {
const result = await processHtml("<code>const x = 1</code>");
expect(result).not.toContain("data-sd-animate");
expect(result).toContain("const x = 1");
expect(result).toContain("data-sd-animate");
expect(result).toContain("const ");
expect(result).toContain("x ");
expect(result).toContain("= ");
expect(result).toContain(">1<");
});

it("should not animate text inside pre elements", async () => {
const result = await processHtml("<pre>some code</pre>");
expect(result).not.toContain("data-sd-animate");
});

it("should not animate text inside pre > code (fenced blocks)", async () => {
const result = await processHtml("<pre><code>const x = 1</code></pre>");
expect(result).not.toContain("data-sd-animate");
expect(result).toContain("const x = 1");
});

it("should not animate text inside svg elements", async () => {
const result = await processHtml("<svg><text>label</text></svg>");
expect(result).not.toContain("data-sd-animate");
});

it("should animate text outside code but not inside", async () => {
const result = await processHtml("<p>Hello <code>world</code> foo</p>");
// "Hello" and "foo" should be animated
it("should animate prose and inline code, but not fenced pre", async () => {
const result = await processHtml(
"<p>Hello <code>world</code> foo</p><pre><code>block</code></pre>"
);
expect(result).toContain("data-sd-animate");
// "world" inside code should NOT be animated
const codeMatch = result.match(CODE_CONTENT_RE);
expect(codeMatch?.[1]).toBe("world");
// Inline code is animated — its text sits inside animate spans.
expect(result).toMatch(INLINE_CODE_ANIMATE_RE);
// Fenced block stays a bare text child (no animate spans under pre).
expect(result).toMatch(FENCED_PRE_BARE_RE);
expect(result).not.toMatch(PRE_ANIMATE_RE);
});
});

Expand Down
5 changes: 4 additions & 1 deletion packages/streamdown/lib/animate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,10 @@ export interface AnimateOptions {

const WHITESPACE_RE = /\s/;
const WHITESPACE_ONLY_RE = /^\s+$/;
const SKIP_TAGS = new Set(["code", "pre", "svg", "math", "annotation"]);
// Skip layout-sensitive subtrees. Fenced/highlighted blocks are protected via
// `pre` (CommonMark always emits `pre > code`); raw inline `code` is safe to
// animate — word spans inherit styles the same way surrounding prose does (#594).
const SKIP_TAGS = new Set(["pre", "svg", "math", "annotation"]);
// Elements with no text node of their own that should still animate in. They
// honor opacity/filter/transform, so they reuse the standard [data-sd-animate]
// rule and work with every animation type.
Expand Down
Loading