From 1ffdbd02954a7ad6f08a5fe61d26a641c4fc65d7 Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Mon, 24 Aug 2026 11:58:25 +0200 Subject: [PATCH 1/2] fix(animate): animate inline code during streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .changeset/animate-inline-code.md | 9 +++++ apps/website/content/docs/animation.mdx | 9 +++-- packages/streamdown/__tests__/animate.test.ts | 37 ++++++++++++++----- packages/streamdown/lib/animate.ts | 5 ++- 4 files changed, 45 insertions(+), 15 deletions(-) create mode 100644 .changeset/animate-inline-code.md diff --git a/.changeset/animate-inline-code.md b/.changeset/animate-inline-code.md new file mode 100644 index 00000000..54fd4bbe --- /dev/null +++ b/.changeset/animate-inline-code.md @@ -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 diff --git a/apps/website/content/docs/animation.mdx b/apps/website/content/docs/animation.mdx index ece5548a..20a08a2e 100644 --- a/apps/website/content/docs/animation.mdx +++ b/apps/website/content/docs/animation.mdx @@ -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 `` 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." @@ -197,13 +197,14 @@ const animate = createAnimatePlugin({ The animation skips text inside these elements to avoid breaking their layout: -- `` — inline and block code -- `
` — preformatted text
+- `
` — preformatted / fenced code blocks (CommonMark always emits `pre > code`, so Shiki-highlighted blocks stay un-split)
 - `` — vector graphics
 - `` — MathML elements
 - `` — MathML annotations
 
-This means code blocks, syntax-highlighted code, math equations, and diagrams render without animation spans.
+Inline `` (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
 
diff --git a/packages/streamdown/__tests__/animate.test.ts b/packages/streamdown/__tests__/animate.test.ts
index a1b32de5..91fed70b 100644
--- a/packages/streamdown/__tests__/animate.test.ts
+++ b/packages/streamdown/__tests__/animate.test.ts
@@ -15,7 +15,7 @@ const SPAN_GAP_CHAR_RE = /<\/span> ]*>tHello <\/span>/;
 const WORLD_SPAN_RE = />world<\/span>/;
 const I_SPACE_SPAN_RE = />i <\/span>/;
-const CODE_CONTENT_RE = /([^<]*)<\/code>/;
+
 const INPUT_TAG_RE = /]*>/;
 const INPUT_TAG_GLOBAL_RE = /]*>/g;
 const IMG_TAG_RE = /]*>/;
@@ -114,10 +114,14 @@ describe("animate plugin", () => {
   });
 
   describe("skip tags", () => {
-    it("should not animate text inside code elements", async () => {
+    // Inline  is layout-neutral to word-span wrapping (#594).
+    it("should animate text inside inline code elements", async () => {
       const result = await processHtml("const x = 1");
-      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 () => {
@@ -125,18 +129,31 @@ describe("animate plugin", () => {
       expect(result).not.toContain("data-sd-animate");
     });
 
+    it("should not animate text inside pre > code (fenced blocks)", async () => {
+      const result = await processHtml(
+        "
const x = 1
" + ); + 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("label"); expect(result).not.toContain("data-sd-animate"); }); - it("should animate text outside code but not inside", async () => { - const result = await processHtml("

Hello world foo

"); - // "Hello" and "foo" should be animated + it("should animate prose and inline code, but not fenced pre", async () => { + const result = await processHtml( + "

Hello world foo

block
" + ); 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( + /]*>[\s\S]*data-sd-animate[\s\S]*world[\s\S]*<\/code>/ + ); + // Fenced block stays a bare text child (no animate spans under pre). + expect(result).toMatch(/
block<\/code><\/pre>/);
+      expect(result).not.toMatch(/
[\s\S]*data-sd-animate/);
     });
   });
 
diff --git a/packages/streamdown/lib/animate.ts b/packages/streamdown/lib/animate.ts
index 01b80802..e3820385 100644
--- a/packages/streamdown/lib/animate.ts
+++ b/packages/streamdown/lib/animate.ts
@@ -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.

From 8e8cf9bf7d55e59d53178987f71147e5a2e79d05 Mon Sep 17 00:00:00 2001
From: Farnabaz 
Date: Mon, 24 Aug 2026 12:01:59 +0200
Subject: [PATCH 2/2] test(animate): hoist inline-code skip regexes for lint

---
 packages/streamdown/__tests__/animate.test.ts | 16 ++++++++--------
 1 file changed, 8 insertions(+), 8 deletions(-)

diff --git a/packages/streamdown/__tests__/animate.test.ts b/packages/streamdown/__tests__/animate.test.ts
index 91fed70b..ff35d275 100644
--- a/packages/streamdown/__tests__/animate.test.ts
+++ b/packages/streamdown/__tests__/animate.test.ts
@@ -15,6 +15,10 @@ const SPAN_GAP_CHAR_RE = /<\/span> ]*>tHello <\/span>/;
 const WORLD_SPAN_RE = />world<\/span>/;
 const I_SPACE_SPAN_RE = />i <\/span>/;
+const INLINE_CODE_ANIMATE_RE =
+  /]*>[\s\S]*data-sd-animate[\s\S]*world[\s\S]*<\/code>/;
+const FENCED_PRE_BARE_RE = /
block<\/code><\/pre>/;
+const PRE_ANIMATE_RE = /
[\s\S]*data-sd-animate/;
 
 const INPUT_TAG_RE = /]*>/;
 const INPUT_TAG_GLOBAL_RE = /]*>/g;
@@ -130,9 +134,7 @@ describe("animate plugin", () => {
     });
 
     it("should not animate text inside pre > code (fenced blocks)", async () => {
-      const result = await processHtml(
-        "
const x = 1
" - ); + const result = await processHtml("
const x = 1
"); expect(result).not.toContain("data-sd-animate"); expect(result).toContain("const x = 1"); }); @@ -148,12 +150,10 @@ describe("animate plugin", () => { ); expect(result).toContain("data-sd-animate"); // Inline code is animated — its text sits inside animate spans. - expect(result).toMatch( - /]*>[\s\S]*data-sd-animate[\s\S]*world[\s\S]*<\/code>/ - ); + expect(result).toMatch(INLINE_CODE_ANIMATE_RE); // Fenced block stays a bare text child (no animate spans under pre). - expect(result).toMatch(/
block<\/code><\/pre>/);
-      expect(result).not.toMatch(/
[\s\S]*data-sd-animate/);
+      expect(result).toMatch(FENCED_PRE_BARE_RE);
+      expect(result).not.toMatch(PRE_ANIMATE_RE);
     });
   });