diff --git a/.changeset/tidy-aliens-swim.md b/.changeset/tidy-aliens-swim.md
new file mode 100644
index 00000000..6861f4e1
--- /dev/null
+++ b/.changeset/tidy-aliens-swim.md
@@ -0,0 +1,10 @@
+---
+"streamdown": minor
+---
+
+Fix Mermaid diagrams so text is readable and diagrams auto-fit container.
+- Normalize SVG to remove responsive shrinking
+- Extract intrinsic size from viewBox
+- Add width-and-height auto-fit in PanZoom
+- Preserve user zoom/pan after initial fit
+- Add tests for SVG utilities and auto-fit behavior
diff --git a/packages/streamdown/__tests__/mermaid-utils.test.ts b/packages/streamdown/__tests__/mermaid-utils.test.ts
index 3baae087..bc2fb909 100644
--- a/packages/streamdown/__tests__/mermaid-utils.test.ts
+++ b/packages/streamdown/__tests__/mermaid-utils.test.ts
@@ -1,5 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { svgToPngBlob } from "../lib/mermaid/utils";
+import {
+ getMermaidSvgSize,
+ normalizeMermaidInlineSvg,
+ svgToPngBlob,
+} from "../lib/mermaid/utils";
const BASE64_SVG_DATA_URL_REGEX = /^data:image\/svg\+xml;base64,/;
@@ -133,3 +137,47 @@ describe("svgToPngBlob", () => {
expect(mockImage.src).toMatch(BASE64_SVG_DATA_URL_REGEX);
});
});
+
+describe("normalizeMermaidInlineSvg", () => {
+ it("should preserve source when no SVG element exists", () => {
+ const input = "
not svg
";
+ expect(normalizeMermaidInlineSvg(input)).toBe(input);
+ });
+
+ it("should preserve source when viewBox is missing", () => {
+ const input = '';
+ expect(normalizeMermaidInlineSvg(input)).toBe(input);
+ });
+
+ it("should normalize width/height/maxWidth from viewBox", () => {
+ const input =
+ '';
+
+ const output = normalizeMermaidInlineSvg(input);
+ const doc = new DOMParser().parseFromString(output, "image/svg+xml");
+ const svg = doc.querySelector("svg");
+
+ expect(svg).toBeTruthy();
+ expect(svg?.getAttribute("width")).toBe("3000");
+ expect(svg?.getAttribute("height")).toBe("800");
+ const style = svg?.getAttribute("style") ?? "";
+ expect(style).toContain("width:3000px");
+ expect(style).toContain("height:800px");
+ expect(style).toContain("max-width:none");
+ });
+});
+
+describe("getMermaidSvgSize", () => {
+ it("should return null when svg is missing", () => {
+ expect(getMermaidSvgSize("")).toBeNull();
+ });
+
+ it("should return null when viewBox is missing", () => {
+ expect(getMermaidSvgSize('')).toBeNull();
+ });
+
+ it("should parse width and height from viewBox", () => {
+ const size = getMermaidSvgSize('');
+ expect(size).toEqual({ height: 1200, width: 3400 });
+ });
+});
diff --git a/packages/streamdown/__tests__/pan-zoom.test.tsx b/packages/streamdown/__tests__/pan-zoom.test.tsx
index 30f3fef3..a5028149 100644
--- a/packages/streamdown/__tests__/pan-zoom.test.tsx
+++ b/packages/streamdown/__tests__/pan-zoom.test.tsx
@@ -1,5 +1,5 @@
import { act, fireEvent, render, waitFor } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
+import { describe, expect, it, vi } from "vitest";
import { PanZoom } from "../lib/mermaid/pan-zoom";
describe("PanZoom", () => {
@@ -295,4 +295,30 @@ describe("PanZoom", () => {
// Check the actual style property, not the attribute string
expect(content?.style.touchAction).toBe("none");
});
+
+ it("should auto-fit large content to width and height", async () => {
+ const widthSpy = vi
+ .spyOn(HTMLElement.prototype, "clientWidth", "get")
+ .mockReturnValue(500);
+ const heightSpy = vi
+ .spyOn(HTMLElement.prototype, "clientHeight", "get")
+ .mockReturnValue(250);
+
+ try {
+ const { container } = render(
+
+ Content
+
+ );
+
+ await waitFor(() => {
+ const content = container.querySelector('[role="application"]');
+ const transform = content?.getAttribute("style") ?? "";
+ expect(transform).toContain("scale(0.25)");
+ });
+ } finally {
+ widthSpy.mockRestore();
+ heightSpy.mockRestore();
+ }
+ });
});
diff --git a/packages/streamdown/lib/mermaid/index.tsx b/packages/streamdown/lib/mermaid/index.tsx
index 3f483863..c756fed5 100644
--- a/packages/streamdown/lib/mermaid/index.tsx
+++ b/packages/streamdown/lib/mermaid/index.tsx
@@ -5,6 +5,7 @@ import { StreamdownContext } from "../../index";
import { useMermaidPlugin } from "../plugin-context";
import { useCn } from "../prefix-context";
import { PanZoom } from "./pan-zoom";
+import { getMermaidSvgSize, normalizeMermaidInlineSvg } from "./utils";
interface MermaidProps {
chart: string;
@@ -25,6 +26,9 @@ export const Mermaid = ({
const [error, setError] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [svgContent, setSvgContent] = useState("");
+ const [svgSize, setSvgSize] = useState<{ height: number; width: number } | null>(
+ null
+ );
const [lastValidSvg, setLastValidSvg] = useState("");
const [retryCount, setRetryCount] = useState(0);
const { mermaid: mermaidContext } = useContext(StreamdownContext);
@@ -67,10 +71,13 @@ export const Mermaid = ({
const uniqueId = `mermaid-${Math.abs(chartHash)}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
const { svg } = await mermaid.render(uniqueId, chart);
+ const size = getMermaidSvgSize(svg);
+ const normalizedSvg = fullscreen ? svg : normalizeMermaidInlineSvg(svg);
// Update both current and last valid SVG
- setSvgContent(svg);
- setLastValidSvg(svg);
+ setSvgContent(normalizedSvg);
+ setSvgSize(size);
+ setLastValidSvg(normalizedSvg);
} catch (err) {
// Silently fail and keep the last valid SVG
// Don't update svgContent here - just keep what we have
@@ -170,7 +177,10 @@ export const Mermaid = ({
fullscreen ? "size-full overflow-hidden" : "overflow-hidden",
className
)}
+ contentSize={svgSize}
+ fitKey={chart}
fullscreen={fullscreen}
+ isAutoFit={true}
maxZoom={3}
minZoom={0.5}
showControls={showControls}
diff --git a/packages/streamdown/lib/mermaid/pan-zoom.tsx b/packages/streamdown/lib/mermaid/pan-zoom.tsx
index c618f77b..5267c271 100644
--- a/packages/streamdown/lib/mermaid/pan-zoom.tsx
+++ b/packages/streamdown/lib/mermaid/pan-zoom.tsx
@@ -6,8 +6,11 @@ import { useCn } from "../prefix-context";
interface PanZoomProps {
children: ReactNode;
className?: string;
+ contentSize?: { height: number; width: number } | null;
+ fitKey?: string;
fullscreen?: boolean;
initialZoom?: number;
+ isAutoFit?: boolean;
maxZoom?: number;
minZoom?: number;
showControls?: boolean;
@@ -17,31 +20,41 @@ interface PanZoomProps {
export const PanZoom = ({
children,
className,
+ contentSize,
+ fitKey,
minZoom = 0.5,
maxZoom = 3,
zoomStep = 0.1,
showControls = true,
initialZoom = 1,
+ isAutoFit = false,
fullscreen = false,
}: PanZoomProps) => {
const { RotateCcwIcon, ZoomInIcon, ZoomOutIcon } = useIcons();
const cn = useCn();
const containerRef = useRef(null);
const contentRef = useRef(null);
+ const [baseZoom, setBaseZoom] = useState(initialZoom);
+ const [effectiveMinZoom, setEffectiveMinZoom] = useState(minZoom);
const [zoom, setZoom] = useState(initialZoom);
const [pan, setPan] = useState({ x: 0, y: 0 });
const [isPanning, setIsPanning] = useState(false);
+ const [hasUserInteracted, setHasUserInteracted] = useState(false);
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
const [panStartPosition, setPanStartPosition] = useState({ x: 0, y: 0 });
const handleZoom = useCallback(
(delta: number) => {
setZoom((prevZoom) => {
- const newZoom = Math.max(minZoom, Math.min(maxZoom, prevZoom + delta));
+ const newZoom = Math.max(
+ effectiveMinZoom,
+ Math.min(maxZoom, prevZoom + delta)
+ );
return newZoom;
});
+ setHasUserInteracted(true);
},
- [minZoom, maxZoom]
+ [effectiveMinZoom, maxZoom]
);
const handleZoomIn = useCallback(() => {
@@ -53,9 +66,10 @@ export const PanZoom = ({
}, [handleZoom, zoomStep]);
const handleReset = useCallback(() => {
- setZoom(initialZoom);
+ setZoom(baseZoom);
setPan({ x: 0, y: 0 });
- }, [initialZoom]);
+ setHasUserInteracted(false);
+ }, [baseZoom]);
const handleWheel = useCallback(
(e: WheelEvent) => {
@@ -73,6 +87,7 @@ export const PanZoom = ({
return;
}
setIsPanning(true);
+ setHasUserInteracted(true);
setPanStart({ x: e.clientX, y: e.clientY });
setPanStartPosition(pan);
// Capture the pointer to track it even outside the element
@@ -110,6 +125,58 @@ export const PanZoom = ({
}
}, []);
+ useEffect(() => {
+ setEffectiveMinZoom(minZoom);
+ if (!isAutoFit) {
+ setBaseZoom(initialZoom);
+ setZoom(initialZoom);
+ }
+ }, [initialZoom, isAutoFit, minZoom]);
+
+ useEffect(() => {
+ if (!isAutoFit || !contentSize) {
+ return;
+ }
+
+ const container = containerRef.current;
+ if (!container) {
+ return;
+ }
+
+ const containerWidth = container.clientWidth;
+ const containerHeight = container.clientHeight;
+
+ if (!(containerWidth > 0 && containerHeight > 0)) {
+ return;
+ }
+
+ const fitZoom = Math.min(
+ containerWidth / contentSize.width,
+ containerHeight / contentSize.height,
+ 1
+ );
+
+ if (!(fitZoom > 0) || Number.isNaN(fitZoom)) {
+ return;
+ }
+
+ setBaseZoom(fitZoom);
+ setEffectiveMinZoom(Math.min(minZoom, fitZoom));
+
+ if (!hasUserInteracted) {
+ setZoom(fitZoom);
+ setPan({ x: 0, y: 0 });
+ }
+ }, [contentSize, hasUserInteracted, isAutoFit, minZoom]);
+
+ useEffect(() => {
+ if (!isAutoFit) {
+ return;
+ }
+
+ setHasUserInteracted(false);
+ }, [fitKey, isAutoFit]);
+
useEffect(() => {
const container = containerRef.current;
/* v8 ignore next */
@@ -181,7 +248,7 @@ export const PanZoom = ({
className={cn(
"flex items-center justify-center rounded p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
)}
- disabled={zoom <= minZoom}
+ disabled={zoom <= effectiveMinZoom}
onClick={handleZoomOut}
title="Zoom out"
type="button"
diff --git a/packages/streamdown/lib/mermaid/utils.ts b/packages/streamdown/lib/mermaid/utils.ts
index 0242d786..5414fc49 100644
--- a/packages/streamdown/lib/mermaid/utils.ts
+++ b/packages/streamdown/lib/mermaid/utils.ts
@@ -1,3 +1,106 @@
+/**
+ * Normalize Mermaid SVG dimensions for inline rendering.
+ * Mermaid emits width="100%" with max-width style, which can shrink very wide
+ * diagrams until text becomes unreadable.
+ */
+export const getMermaidSvgSize = (
+ svgString: string
+): { height: number; width: number } | null => {
+ const svgTagMatch = svgString.match(/