From 7be41e81757af893068ac7e6a13ec4ea91aa6388 Mon Sep 17 00:00:00 2001 From: shadcn Date: Mon, 22 Jun 2026 20:05:49 +0400 Subject: [PATCH] fix: marker, hr and img streaming animation --- .changeset/gold-onions-fly.md | 5 + packages/streamdown/__tests__/animate.test.ts | 160 ++++++++++++++++++ .../__tests__/void-element-animation.test.tsx | 67 ++++++++ packages/streamdown/lib/animate.ts | 146 +++++++++++++++- packages/streamdown/styles.css | 18 ++ 5 files changed, 394 insertions(+), 2 deletions(-) create mode 100644 .changeset/gold-onions-fly.md create mode 100644 packages/streamdown/__tests__/void-element-animation.test.tsx diff --git a/.changeset/gold-onions-fly.md b/.changeset/gold-onions-fly.md new file mode 100644 index 00000000..579b418f --- /dev/null +++ b/.changeset/gold-onions-fly.md @@ -0,0 +1,5 @@ +--- +"streamdown": patch +--- + +fix issue with list markers, task-list checkboxes, images, and hr animations diff --git a/packages/streamdown/__tests__/animate.test.ts b/packages/streamdown/__tests__/animate.test.ts index a4a92cbc..07bc5bd1 100644 --- a/packages/streamdown/__tests__/animate.test.ts +++ b/packages/streamdown/__tests__/animate.test.ts @@ -6,6 +6,10 @@ import { animate, createAnimatePlugin } from "../lib/animate"; const SPAN_GAP_RE = /<\/span>\s+([^<]*)<\/code>/; +const INPUT_TAG_RE = /]*>/; +const INPUT_TAG_GLOBAL_RE = /]*>/g; +const IMG_TAG_RE = /]*>/; +const HR_TAG_RE = /]*>/; const processHtml = async (html: string, plugin = animate) => { const processor = unified() @@ -281,4 +285,160 @@ describe("animate plugin", () => { expect(delays).toEqual([]); }); }); + + describe("list marker", () => { + it("should stamp marker animation vars on the list item", async () => { + const result = await processHtml("
  • Hello world
"); + expect(result).toContain("data-sd-animate-marker"); + expect(result).toContain("--sd-marker-duration:150ms"); + expect(result).toContain("--sd-marker-delay:0ms"); + expect(result).toContain("--sd-marker-easing:ease"); + }); + + it("should sync marker delay with the item's first word", async () => { + const plugin = createAnimatePlugin({ stagger: 40 }); + const result = await processHtml( + "
  • Hello world
  • Foo bar
", + plugin + ); + const delays = result.match(/--sd-marker-delay:\d+ms/g) ?? []; + // First item's first word has delay 0; second item's first word is the + // third animated word in the block → 2 * 40ms. + expect(delays).toEqual([ + "--sd-marker-delay:0ms", + "--sd-marker-delay:80ms", + ]); + }); + + it("should respect custom duration and easing", async () => { + const plugin = createAnimatePlugin({ duration: 300, easing: "ease-out" }); + const result = await processHtml("
  • Hello
", plugin); + expect(result).toContain("--sd-marker-duration:300ms"); + expect(result).toContain("--sd-marker-easing:ease-out"); + }); + + it("should not re-animate the marker of an already-rendered item", async () => { + const plugin = createAnimatePlugin(); + await processHtml("
  • Hello
", plugin); + const prevCount = plugin.getLastRenderCharCount(); + + plugin.setPrevContentLength(prevCount); + const result = await processHtml( + "
  • Hello
  • World
", + plugin + ); + + const durations = result.match(/--sd-marker-duration:\d+ms/g) ?? []; + // First (already-shown) item → 0ms; newly added item → 150ms. + expect(durations).toEqual([ + "--sd-marker-duration:0ms", + "--sd-marker-duration:150ms", + ]); + }); + + it("should not stamp marker vars on non-list content", async () => { + const result = await processHtml("

Hello world

"); + expect(result).not.toContain("data-sd-animate-marker"); + }); + }); + + describe("task-list checkbox", () => { + const TASK_ITEM = + '
  • Hello world
'; + + it("should tag the checkbox with data-sd-animate and timing", async () => { + const result = await processHtml(TASK_ITEM); + const input = result.match(INPUT_TAG_RE)?.[0] ?? ""; + expect(input).toContain("data-sd-animate"); + expect(input).toContain("--sd-animation:sd-fadeIn"); + expect(input).toContain("--sd-duration:150ms"); + expect(input).toContain("--sd-delay:0ms"); + }); + + it("should sync the checkbox with the configured animation", async () => { + const plugin = createAnimatePlugin({ animation: "slideUp" }); + const result = await processHtml(TASK_ITEM, plugin); + const input = result.match(INPUT_TAG_RE)?.[0] ?? ""; + expect(input).toContain("--sd-animation:sd-slideUp"); + }); + + it("should not re-animate the checkbox of an already-rendered item", async () => { + const plugin = createAnimatePlugin(); + await processHtml(TASK_ITEM, plugin); + const prevCount = plugin.getLastRenderCharCount(); + + plugin.setPrevContentLength(prevCount); + const result = await processHtml(TASK_ITEM, plugin); + const input = result.match(INPUT_TAG_RE)?.[0] ?? ""; + expect(input).toContain("--sd-duration:0ms"); + }); + + it("should not tag inputs outside of list items", async () => { + const result = await processHtml( + '

Hello world

' + ); + const input = result.match(INPUT_TAG_RE)?.[0] ?? ""; + expect(input).not.toContain("data-sd-animate"); + }); + + it("should not let a parent item capture a nested item's checkbox", async () => { + // A regular outer item containing a task sub-item: the outer item has no + // checkbox of its own and must not stamp the nested one. + const result = await processHtml( + '
  • Outer item
    • ' + + ' Nested task
' + ); + const inputs = result.match(INPUT_TAG_GLOBAL_RE) ?? []; + expect(inputs).toHaveLength(1); + // The lone checkbox should be tagged once, with the nested item's own + // delay. It is the 3rd animated word ("Outer"=0, "item"=40, "Nested"=80). + expect(inputs[0]).toContain("data-sd-animate"); + expect(inputs[0]).toContain("--sd-delay:80ms"); + }); + }); + + describe("void elements (img / hr)", () => { + it("should tag an image with data-sd-animate and timing", async () => { + const result = await processHtml('

x

'); + const img = result.match(IMG_TAG_RE)?.[0] ?? ""; + expect(img).toContain("data-sd-animate"); + expect(img).toContain("--sd-animation:sd-fadeIn"); + expect(img).toContain("--sd-duration:150ms"); + }); + + it("should tag a horizontal rule with data-sd-animate", async () => { + const result = await processHtml("
"); + const hr = result.match(HR_TAG_RE)?.[0] ?? ""; + expect(hr).toContain("data-sd-animate"); + expect(hr).toContain("--sd-animation:sd-fadeIn"); + }); + + it("should use the configured animation type", async () => { + const plugin = createAnimatePlugin({ animation: "blurIn" }); + const result = await processHtml('

', plugin); + const img = result.match(IMG_TAG_RE)?.[0] ?? ""; + expect(img).toContain("--sd-animation:sd-blurIn"); + }); + + it("should preserve an existing inline style", async () => { + const result = await processHtml(''); + const img = result.match(IMG_TAG_RE)?.[0] ?? ""; + expect(img).toContain("width:10px"); + expect(img).toContain("--sd-animation"); + }); + + it("should not animate void elements inside skip tags", async () => { + const result = await processHtml('
'); + const img = result.match(IMG_TAG_RE)?.[0] ?? ""; + expect(img).not.toContain("data-sd-animate"); + }); + + it("should advance the stagger sequence alongside words", async () => { + const plugin = createAnimatePlugin({ stagger: 50 }); + const result = await processHtml("

Hello


World

", plugin); + // Hello=word0(delay 0), hr=slot1(delay 50), World=word2(delay 100) + expect(result).toContain("--sd-delay:50ms"); + expect(result).toContain("--sd-delay:100ms"); + }); + }); }); diff --git a/packages/streamdown/__tests__/void-element-animation.test.tsx b/packages/streamdown/__tests__/void-element-animation.test.tsx new file mode 100644 index 00000000..f7220010 --- /dev/null +++ b/packages/streamdown/__tests__/void-element-animation.test.tsx @@ -0,0 +1,67 @@ +/** + * React-level coverage for animating non-text elements (list markers, + * task-list checkboxes, images, horizontal rules). + * + * The animate plugin emits these as HAST properties with a *string* style. + * These tests confirm react-markdown converts that string into real DOM + * attributes / CSS custom properties through the memo'd components — something + * the plugin-level (rehype string) tests in animate.test.ts can't verify. + */ + +import { act, render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { Streamdown } from "../index"; + +const animated = { + animation: "fadeIn" as const, + duration: 500, + easing: "ease", +}; + +const renderAnimated = async (markdown: string) => { + const result = render( + + {markdown} + + ); + await act(() => Promise.resolve()); + return result; +}; + +describe("void element animation (React render)", () => { + it("stamps the marker timing onto a list item", async () => { + const { container } = await renderAnimated("- First item\n"); + const li = container.querySelector('[data-streamdown="list-item"]'); + expect(li).not.toBeNull(); + expect(li?.getAttribute("data-sd-animate-marker")).not.toBeNull(); + const style = li?.getAttribute("style") ?? ""; + expect(style).toContain("--sd-marker-duration"); + expect(style).toContain("--sd-marker-easing"); + }); + + it("animates a task-list checkbox", async () => { + const { container } = await renderAnimated("- [ ] A task item\n"); + const input = container.querySelector('input[type="checkbox"]'); + expect(input).not.toBeNull(); + expect(input?.getAttribute("data-sd-animate")).not.toBeNull(); + expect(input?.getAttribute("style") ?? "").toContain("--sd-animation"); + }); + + it("animates an image", async () => { + const { container } = await renderAnimated( + "![alt](https://example.com/a.png)\n" + ); + const img = container.querySelector('[data-streamdown="image"]'); + expect(img).not.toBeNull(); + expect(img?.getAttribute("data-sd-animate")).not.toBeNull(); + expect(img?.getAttribute("style") ?? "").toContain("--sd-animation"); + }); + + it("animates a horizontal rule", async () => { + const { container } = await renderAnimated("Above\n\n---\n\nBelow\n"); + const hr = container.querySelector('[data-streamdown="horizontal-rule"]'); + expect(hr).not.toBeNull(); + expect(hr?.getAttribute("data-sd-animate")).not.toBeNull(); + expect(hr?.getAttribute("style") ?? "").toContain("--sd-animation"); + }); +}); diff --git a/packages/streamdown/lib/animate.ts b/packages/streamdown/lib/animate.ts index 53886b93..55879616 100644 --- a/packages/streamdown/lib/animate.ts +++ b/packages/streamdown/lib/animate.ts @@ -31,6 +31,10 @@ export interface AnimateOptions { const WHITESPACE_RE = /\s/; const WHITESPACE_ONLY_RE = /^\s+$/; const SKIP_TAGS = new Set(["code", "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. +const VOID_ANIMATE_TAGS = new Set(["img", "hr"]); const isElement = (node: unknown): node is Element => typeof node === "object" && @@ -43,6 +47,109 @@ const hasSkipAncestor = (ancestors: Node[]): boolean => (ancestor) => isElement(ancestor) && SKIP_TAGS.has(ancestor.tagName) ); +const findLiAncestor = (ancestors: Node[]): Element | undefined => { + for (let i = ancestors.length - 1; i >= 0; i--) { + const ancestor = ancestors[i]; + if (isElement(ancestor) && ancestor.tagName === "li") { + return ancestor; + } + } + return undefined; +}; + +// The ::marker glyph can't be wrapped in an animated span, so we stamp +// timing onto the
  • and let a CSS rule fade the marker's color in. +// Timing mirrors the item's first animated word so they appear together. +const stampMarker = ( + li: Element, + duration: number, + delay: number, + easing: string +): void => { + li.properties ??= {}; + li.properties["data-sd-animate-marker"] = true; + li.properties.style = + `--sd-marker-duration:${duration}ms;` + + `--sd-marker-delay:${delay}ms;--sd-marker-easing:${easing}`; +}; + +// A task-list checkbox is a direct child of its
  • (or nested in a

    for +// loose lists). Recurse to reach it, but never cross into a nested list, or we +// could grab a sub-item's checkbox and stamp it with the wrong timing. +const LIST_CONTAINER_TAGS = new Set(["ul", "ol", "li"]); +const findCheckbox = (element: Element): Element | undefined => { + for (const child of element.children) { + if (isElement(child)) { + if (child.tagName === "input") { + return child; + } + if (LIST_CONTAINER_TAGS.has(child.tagName)) { + continue; + } + const nested = findCheckbox(child); + if (nested) { + return nested; + } + } + } + return undefined; +}; + +// Tag a non-text element so the standard [data-sd-animate] rule animates it, +// using the same timing/config the text spans use. Any existing inline style +// is preserved. +const stampAnimation = ( + element: Element, + config: AnimateConfig, + duration: number, + delay: number +): void => { + element.properties ??= {}; + element.properties["data-sd-animate"] = true; + const existing = + typeof element.properties.style === "string" + ? `${element.properties.style};` + : ""; + element.properties.style = + `${existing}--sd-animation:sd-${config.animation};` + + `--sd-duration:${duration}ms;--sd-easing:${config.easing};` + + `--sd-delay:${delay}ms`; +}; + +// Task-list checkboxes are elements, not text nodes, so the plugin +// never wraps them. Unlike ::marker, an honors opacity/transform, so +// we reuse the same timing as the item's first word. +const stampCheckbox = ( + li: Element, + config: AnimateConfig, + duration: number, + delay: number +): void => { + const input = findCheckbox(li); + if (input) { + stampAnimation(input, config, duration, delay); + } +}; + +// Images and rules have no text node, so they're tagged directly. Their +// "already shown" state is judged by document position (charCounter.count) +// rather than character length, since they contribute no characters. +const processVoidElement = ( + element: Element, + ancestors: Node[], + config: AnimateConfig, + renderState: AnimateRenderState, + charCounter: { count: number; newIndex: number } +): void => { + if (hasSkipAncestor(ancestors)) { + return; + } + const prevLen = renderState.prevContentLength; + const skipAnimation = prevLen > 0 && charCounter.count < prevLen; + const delay = skipAnimation ? 0 : charCounter.newIndex++ * config.stagger; + stampAnimation(element, config, skipAnimation ? 0 : config.duration, delay); +}; + const splitByWord = (text: string): string[] => { const parts: string[] = []; let current = ""; @@ -163,6 +270,14 @@ const processTextNode = ( const parts = config.sep === "char" ? splitByChar(text) : splitByWord(text); const prevLen = renderState.prevContentLength; + // Fade the list marker in with this item's first animated word. Only the + // first word of the nearest

  • stamps it; later words leave it untouched. + const liAncestor = findLiAncestor(ancestors); + const needsMarker = Boolean( + liAncestor && !liAncestor.properties?.["data-sd-animate-marker"] + ); + let markerStamped = false; + const nodes: (Element | Text)[] = parts.map((part) => { const partStart = charCounter.count; charCounter.count += part.length; @@ -171,6 +286,12 @@ const processTextNode = ( } const skipAnimation = prevLen > 0 && partStart < prevLen; const delay = skipAnimation ? 0 : charCounter.newIndex++ * config.stagger; + if (liAncestor && needsMarker && !markerStamped) { + const itemDuration = skipAnimation ? 0 : config.duration; + stampMarker(liAncestor, itemDuration, delay, config.easing); + stampCheckbox(liAncestor, config, itemDuration, delay); + markerStamped = true; + } return makeSpan( part, config.animation, @@ -210,8 +331,29 @@ export function createAnimatePlugin(options?: AnimateOptions): AnimatePlugin { const id = instanceId++; const rehypeAnimate = () => (tree: Root) => { const charCounter = { count: 0, newIndex: 0 }; - visitParents(tree, "text", (node: Text, ancestors) => - processTextNode(node, ancestors, config, renderState, charCounter) + visitParents( + tree, + (node: Node) => + node.type === "text" || + (isElement(node) && VOID_ANIMATE_TAGS.has(node.tagName)), + (node: Node, ancestors) => { + if (node.type === "text") { + return processTextNode( + node as Text, + ancestors, + config, + renderState, + charCounter + ); + } + processVoidElement( + node as Element, + ancestors, + config, + renderState, + charCounter + ); + } ); renderState.lastRenderCharCount = charCounter.count; // Self-reset so sibling blocks don't inherit this block's value. diff --git a/packages/streamdown/styles.css b/packages/streamdown/styles.css index 76dae5ef..fd5e7f01 100644 --- a/packages/streamdown/styles.css +++ b/packages/streamdown/styles.css @@ -33,3 +33,21 @@ animation: var(--sd-animation, sd-fadeIn) var(--sd-duration, 150ms) var(--sd-easing, ease) var(--sd-delay, 0ms) both; } + +/* + * List markers (the disc/decimal glyph) are painted by the browser's + * ::marker pseudo-element, which the animate plugin can't wrap in a + * span. ::marker also doesn't honor opacity/transform animations, so we + * fade it in via color (which it does support) instead, synced to the + * timing the plugin stamps from the item's first word. + */ +@keyframes sd-markerIn { + from { + color: transparent; + } +} + +[data-sd-animate-marker]::marker { + animation: sd-markerIn var(--sd-marker-duration, 150ms) + var(--sd-marker-easing, ease) var(--sd-marker-delay, 0ms) both; +}