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..ff35d275 100644
--- a/packages/streamdown/__tests__/animate.test.ts
+++ b/packages/streamdown/__tests__/animate.test.ts
@@ -15,7 +15,11 @@ 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 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;
 const IMG_TAG_RE = /]*>/;
@@ -114,10 +118,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 +133,27 @@ 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(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); }); }); 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.