diff --git a/.changeset/clean-settled-animation-spans.md b/.changeset/clean-settled-animation-spans.md
new file mode 100644
index 00000000..629bffb8
--- /dev/null
+++ b/.changeset/clean-settled-animation-spans.md
@@ -0,0 +1,5 @@
+---
+"streamdown": patch
+---
+
+Fix animation spans not being removed from settled text when `isAnimating` goes false. Include `isAnimating` in block keys so React mounts fresh subtrees when the animate rehype plugin is removed from the pipeline, allowing the span-free reparse to commit.
diff --git a/packages/streamdown/__tests__/animate-integration.test.tsx b/packages/streamdown/__tests__/animate-integration.test.tsx
new file mode 100644
index 00000000..3419c21a
--- /dev/null
+++ b/packages/streamdown/__tests__/animate-integration.test.tsx
@@ -0,0 +1,37 @@
+import { render } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { Streamdown } from "../index";
+
+const FADE = {
+ animation: "fadeIn",
+ duration: 200,
+ easing: "ease-out",
+ sep: "word",
+ stagger: 0,
+} as const;
+
+const MD =
+ "First paragraph with several words here.\n\nSecond paragraph also has words.\n\nThird paragraph ends it.";
+
+describe("Animate integration", () => {
+ it("should remove animation spans when isAnimating goes false", () => {
+ const { container, rerender } = render(
+
+ {MD}
+
+ );
+ expect(
+ container.querySelectorAll("[data-sd-animate]").length
+ ).toBeGreaterThan(0);
+
+ // Stream ends: same children, animation off.
+ rerender(
+
+ {MD}
+
+ );
+ expect(
+ container.querySelectorAll("[data-sd-animate]").length
+ ).toBe(0);
+ });
+});
diff --git a/packages/streamdown/index.tsx b/packages/streamdown/index.tsx
index be32d9fd..e19a0b88 100644
--- a/packages/streamdown/index.tsx
+++ b/packages/streamdown/index.tsx
@@ -572,15 +572,6 @@ export const Streamdown = memo(
[blocksToRender, dir]
);
- // Generate stable keys based on index only
- // Don't use content hash - that causes unmount/remount when content changes
- // React will handle content updates via props changes and memo comparison
- // biome-ignore lint/correctness/useExhaustiveDependencies: "we're using the blocksToRender length"
- const blockKeys = useMemo(
- () => blocksToRender.map((_block, idx) => `${generatedId}-${idx}`),
- [blocksToRender.length, generatedId]
- );
-
// Stable key derived from animated option values. This prevents the
// plugin from being recreated when the user passes an inline object
// literal (e.g. animated={{ animation: 'fadeIn' }}) whose reference
@@ -606,6 +597,21 @@ export const Streamdown = memo(
return createAnimatePlugin(animated as AnimateOptions);
}, [animatedKey]);
+ // Generate stable keys based on index. Don't use content hash — that
+ // causes unmount/remount when content changes. When the animate plugin is
+ // active, also key on isAnimating so that when streaming ends and the
+ // plugin is removed from the rehype pipeline, React mounts fresh subtrees.
+ // Without this, memoized leaf components that compare only className +
+ // source position discard the span-free reparse. When animated is absent,
+ // skip the extra suffix — no spans exist to discard.
+ // biome-ignore lint/correctness/useExhaustiveDependencies: "we're using the blocksToRender length"
+ const blockKeys = useMemo(() => {
+ const suffix = animatePlugin ? (isAnimating ? "a" : "s") : "";
+ return blocksToRender.map(
+ (_block, idx) => `${generatedId}-${idx}-${suffix}`
+ );
+ }, [blocksToRender.length, generatedId, isAnimating, animatePlugin]);
+
// Combined context value - single object reduces React tree overhead
const contextValue = useMemo(
() => ({