Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/afraid-turkeys-guess.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"streamdown": minor
---

# feat: Add optional block animation support
78 changes: 71 additions & 7 deletions packages/streamdown/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,49 @@ import { Markdown, type Options } from "./lib/markdown";
import { parseMarkdownIntoBlocks } from "./lib/parse-blocks";
import { cn } from "./lib/utils";

// Animation CSS - injected only when animate=true
const animationCSS = `
@keyframes streamdownFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.streamdown-animate > * {
animation: streamdownFadeIn var(--streamdown-duration, 300ms) ease-out both;
opacity: 0;
}
.streamdown-animate > *:nth-child(1) { animation-delay: 0ms; }
.streamdown-animate > *:nth-child(2) { animation-delay: 60ms; }
.streamdown-animate > *:nth-child(3) { animation-delay: 120ms; }
.streamdown-animate > *:nth-child(4) { animation-delay: 180ms; }
.streamdown-animate > *:nth-child(n+5) { animation-delay: 240ms; }
@media (prefers-reduced-motion: reduce) {
.streamdown-animate > * {
animation: none !important;
opacity: 1 !important;
}
}
`;

let animationStylesInjected = false;
function injectAnimationStyles() {
if (animationStylesInjected || typeof document === "undefined") return;
const styleId = "streamdown-animation";
if (document.getElementById(styleId)) {
animationStylesInjected = true;
return;
}
const el = document.createElement("style");
el.id = styleId;
el.textContent = animationCSS;
document.head.appendChild(el);
animationStylesInjected = true;
}

const carets = {
block: " ▋",
circle: " ●",
};

export type { MermaidConfig } from "mermaid";
// biome-ignore lint/performance/noBarrelFile: "required"
export { parseMarkdownIntoBlocks } from "./lib/parse-blocks";
Expand Down Expand Up @@ -68,6 +111,10 @@ export type StreamdownProps = Options & {
controls?: ControlsConfig;
isAnimating?: boolean;
caret?: keyof typeof carets;
/** Enable simple fade-in animation on blocks */
animate?: boolean;
/** Fade-in animation duration in ms (default: 300) */
animationDuration?: number;
};

export const defaultRehypePlugins: Record<string, Pluggable> = {
Expand Down Expand Up @@ -97,11 +144,6 @@ export const defaultRemarkPlugins: Record<string, Pluggable> = {
const defaultRehypePluginsArray = Object.values(defaultRehypePlugins);
const defaultRemarkPluginsArray = Object.values(defaultRemarkPlugins);

const carets = {
block: " ▋",
circle: " ●",
};

// Combined context for better performance - reduces React tree depth from 5 nested providers to 1
export type StreamdownContextType = {
shikiTheme: [BundledTheme, BundledTheme];
Expand Down Expand Up @@ -198,6 +240,8 @@ export const Streamdown = memo(
mermaid,
controls = true,
isAnimating = false,
animate = false,
animationDuration = 300,
BlockComponent = Block,
parseMarkdownIntoBlocksFn = parseMarkdownIntoBlocks,
caret,
Expand Down Expand Up @@ -329,15 +373,28 @@ export const Streamdown = memo(
[caret, isAnimating]
);

// Inject animation CSS when animate is enabled
useEffect(() => {
if (animate) {
injectAnimationStyles();
}
}, [animate]);

// Static mode: simple rendering without streaming features
if (mode === "static") {
return (
<StreamdownContext.Provider value={contextValue}>
<div
className={cn(
"space-y-4 whitespace-normal *:first:mt-0 *:last:mb-0",
animate && "streamdown-animate",
className
)}
style={
animate
? ({ "--streamdown-duration": `${animationDuration}ms` } as React.CSSProperties)
: undefined
}
>
<Markdown
components={mergedComponents}
Expand All @@ -361,9 +418,14 @@ export const Streamdown = memo(
caret
? "*:last:after:inline *:last:after:align-baseline *:last:after:content-(--streamdown-caret)"
: undefined,
animate && "streamdown-animate",
className
)}
style={style}
style={
animate
? ({ ...style, "--streamdown-duration": `${animationDuration}ms` } as React.CSSProperties)
: style
}
>
{blocksToRender.map((block, index) => (
<BlockComponent
Expand All @@ -385,6 +447,8 @@ export const Streamdown = memo(
prevProps.children === nextProps.children &&
prevProps.shikiTheme === nextProps.shikiTheme &&
prevProps.isAnimating === nextProps.isAnimating &&
prevProps.mode === nextProps.mode
prevProps.mode === nextProps.mode &&
prevProps.animate === nextProps.animate &&
prevProps.animationDuration === nextProps.animationDuration
);
Streamdown.displayName = "Streamdown";
5 changes: 2 additions & 3 deletions packages/streamdown/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "streamdown",
"version": "1.6.10",
"name": "@coderpr0grammer/streamdown",
"version": "1.6.11-animate.3",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
Expand Down Expand Up @@ -70,7 +70,6 @@
"remark-math": "^6.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",
"remend": "workspace:*",
"shiki": "^3.19.0",
"tailwind-merge": "^3.4.0",
"unified": "^11.0.5",
Expand Down