diff --git a/.changeset/image-controls.md b/.changeset/image-controls.md
new file mode 100644
index 00000000..05ed0f35
--- /dev/null
+++ b/.changeset/image-controls.md
@@ -0,0 +1,9 @@
+---
+"streamdown": minor
+---
+
+feat: add image control options (`controls.image`) to disable hover overlay and download button on images
+
+Add `image` to the `controls` prop, matching existing `code`, `table`, and `mermaid` patterns:
+- `controls={{ image: false }}` hides the hover overlay and download button
+- `controls={{ image: { download: false } }}` keeps the hover overlay but hides the download button
diff --git a/packages/streamdown/__tests__/image.test.tsx b/packages/streamdown/__tests__/image.test.tsx
index 88931292..f134a9b4 100644
--- a/packages/streamdown/__tests__/image.test.tsx
+++ b/packages/streamdown/__tests__/image.test.tsx
@@ -414,3 +414,76 @@ describe("ImageComponent", () => {
expect(img?.getAttribute("data-testid")).toBe("custom-image");
});
});
+
+describe("ImageComponent control props", () => {
+ beforeEach(() => {
+ vi.spyOn(console, "error").mockImplementation(() => {
+ // Intentionally empty
+ });
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("showDownloadControl={false} hides download button after load", () => {
+ const { container } = render(
+
+ );
+
+ const img = container.querySelector('img[data-streamdown="image"]');
+ if (img) {
+ fireEvent.load(img);
+ }
+
+ const button = container.querySelector('button[title="Download image"]');
+ expect(button).toBeFalsy();
+ });
+
+ it("showControls={false} hides both overlay and download button", () => {
+ const { container } = render(
+
+ );
+
+ const img = container.querySelector('img[data-streamdown="image"]');
+ if (img) {
+ fireEvent.load(img);
+ }
+
+ const overlay = container.querySelector(
+ '[data-streamdown="image-overlay"]'
+ );
+ const button = container.querySelector('button[title="Download image"]');
+ expect(overlay).toBeFalsy();
+ expect(button).toBeFalsy();
+ });
+
+ it("showControls={true} (default) shows download button after load", () => {
+ const { container } = render(
+
+ );
+
+ const img = container.querySelector('img[data-streamdown="image"]');
+ if (img) {
+ fireEvent.load(img);
+ }
+
+ const button = container.querySelector('button[title="Download image"]');
+ expect(button).toBeTruthy();
+ });
+});
diff --git a/packages/streamdown/__tests__/show-controls.test.tsx b/packages/streamdown/__tests__/show-controls.test.tsx
index eda3fedc..8c3d285e 100644
--- a/packages/streamdown/__tests__/show-controls.test.tsx
+++ b/packages/streamdown/__tests__/show-controls.test.tsx
@@ -1,4 +1,4 @@
-import { render, waitFor } from "@testing-library/react";
+import { fireEvent, render, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { Streamdown } from "../index";
@@ -441,6 +441,138 @@ graph TD
});
});
+ describe("image controls", () => {
+ const markdownWithImage = "";
+
+ it("controls={false} hides image overlay and download button", async () => {
+ const { container } = render(
+ {markdownWithImage}
+ );
+
+ const img = container.querySelector('[data-streamdown="image"]');
+ if (img) {
+ fireEvent.load(img);
+ }
+
+ await waitFor(() => {
+ const wrapper = container.querySelector(
+ '[data-streamdown="image-wrapper"]'
+ );
+ const buttons = wrapper?.querySelectorAll("button");
+ const overlay = wrapper?.querySelector(
+ '[data-streamdown="image-overlay"]'
+ );
+ expect(buttons?.length).toBe(0);
+ expect(overlay).toBeFalsy();
+ });
+ });
+
+ it("controls={{ image: false }} hides image controls", async () => {
+ const { container } = render(
+ {markdownWithImage}
+ );
+
+ const img = container.querySelector('[data-streamdown="image"]');
+ if (img) {
+ fireEvent.load(img);
+ }
+
+ await waitFor(() => {
+ const wrapper = container.querySelector(
+ '[data-streamdown="image-wrapper"]'
+ );
+ const buttons = wrapper?.querySelectorAll("button");
+ const overlay = wrapper?.querySelector(
+ '[data-streamdown="image-overlay"]'
+ );
+ expect(buttons?.length).toBe(0);
+ expect(overlay).toBeFalsy();
+ });
+ });
+
+ it("controls={{ image: true }} shows image download button after load", async () => {
+ const { container } = render(
+ {markdownWithImage}
+ );
+
+ const img = container.querySelector('[data-streamdown="image"]');
+ if (img) {
+ fireEvent.load(img);
+ }
+
+ await waitFor(() => {
+ const wrapper = container.querySelector(
+ '[data-streamdown="image-wrapper"]'
+ );
+ const button = wrapper?.querySelector('button[title="Download image"]');
+ expect(button).toBeTruthy();
+ });
+ });
+
+ it("controls={{ image: { download: false } }} hides only download button but keeps overlay", async () => {
+ const { container } = render(
+
+ {markdownWithImage}
+
+ );
+
+ const img = container.querySelector('[data-streamdown="image"]');
+ if (img) {
+ fireEvent.load(img);
+ }
+
+ await waitFor(() => {
+ const wrapper = container.querySelector(
+ '[data-streamdown="image-wrapper"]'
+ );
+ const button = wrapper?.querySelector('button[title="Download image"]');
+ const overlay = wrapper?.querySelector(
+ '[data-streamdown="image-overlay"]'
+ );
+ expect(button).toBeFalsy();
+ expect(overlay).toBeTruthy();
+ });
+ });
+
+ it("unspecified image key defaults to showing controls", async () => {
+ const { container } = render(
+ {markdownWithImage}
+ );
+
+ const img = container.querySelector('[data-streamdown="image"]');
+ if (img) {
+ fireEvent.load(img);
+ }
+
+ await waitFor(() => {
+ const wrapper = container.querySelector(
+ '[data-streamdown="image-wrapper"]'
+ );
+ const button = wrapper?.querySelector('button[title="Download image"]');
+ expect(button).toBeTruthy();
+ });
+ });
+
+ it("controls={true} shows image controls by default", async () => {
+ const { container } = render(
+ {markdownWithImage}
+ );
+
+ const img = container.querySelector('[data-streamdown="image"]');
+ if (img) {
+ fireEvent.load(img);
+ }
+
+ await waitFor(() => {
+ const wrapper = container.querySelector(
+ '[data-streamdown="image-wrapper"]'
+ );
+ const button = wrapper?.querySelector('button[title="Download image"]');
+ expect(button).toBeTruthy();
+ });
+ });
+ });
+
describe("with custom components", () => {
it("should respect controls with custom component overrides", () => {
const CustomParagraph = ({ children }: any) => (
diff --git a/packages/streamdown/index.tsx b/packages/streamdown/index.tsx
index be32d9fd..b4dba45f 100644
--- a/packages/streamdown/index.tsx
+++ b/packages/streamdown/index.tsx
@@ -156,6 +156,7 @@ export type ControlsConfig =
fullscreen?: boolean;
panZoom?: boolean;
};
+ image?: boolean | { download?: boolean };
};
export interface LinkSafetyModalProps {
diff --git a/packages/streamdown/lib/components.tsx b/packages/streamdown/lib/components.tsx
index 2449ca3a..cb6eb67d 100644
--- a/packages/streamdown/lib/components.tsx
+++ b/packages/streamdown/lib/components.tsx
@@ -91,7 +91,7 @@ function sameClassAndNode(
const shouldShowControls = (
config: ControlsConfig,
- type: "table" | "code" | "mermaid"
+ type: "table" | "code" | "mermaid" | "image"
) => {
if (typeof config === "boolean") {
return config;
@@ -163,6 +163,27 @@ const shouldShowMermaidControl = (
return mermaidConfig[controlType] !== false;
};
+const shouldShowImageControl = (
+ config: ControlsConfig,
+ controlType: "download"
+): boolean => {
+ if (typeof config === "boolean") {
+ return config;
+ }
+
+ const imageConfig = config.image;
+
+ if (imageConfig === false) {
+ return false;
+ }
+
+ if (imageConfig === true || imageConfig === undefined) {
+ return true;
+ }
+
+ return imageConfig[controlType] !== false;
+};
+
type OlProps = WithNode;
const MemoOl = memo(
({ children, className, node, ...props }: OlProps) => {
@@ -964,11 +985,31 @@ const MemoCode = memo<
);
MemoCode.displayName = "MarkdownCode";
-const MemoImg = memo<
- DetailedHTMLProps, HTMLImageElement> &
- ExtraProps
->(
- ImageComponent,
+type ImgProps = DetailedHTMLProps<
+ ImgHTMLAttributes,
+ HTMLImageElement
+> &
+ ExtraProps;
+
+const ImageWrapper = ({ node, className, ...props }: ImgProps) => {
+ const { controls: controlsConfig } = useContext(StreamdownContext);
+ const showImageControls = shouldShowControls(controlsConfig, "image");
+ const showDownloadControl =
+ showImageControls && shouldShowImageControl(controlsConfig, "download");
+
+ return (
+
+ );
+};
+
+const MemoImg = memo(
+ ImageWrapper,
(p, n) => p.className === n.className && sameNodePosition(p.node, n.node)
);
diff --git a/packages/streamdown/lib/image.tsx b/packages/streamdown/lib/image.tsx
index b6290772..b7dd16ac 100644
--- a/packages/streamdown/lib/image.tsx
+++ b/packages/streamdown/lib/image.tsx
@@ -12,7 +12,10 @@ type ImageComponentProps = DetailedHTMLProps<
ImgHTMLAttributes,
HTMLImageElement
> &
- ExtraProps;
+ ExtraProps & {
+ showControls?: boolean;
+ showDownloadControl?: boolean;
+ };
export const ImageComponent = ({
node,
@@ -21,6 +24,8 @@ export const ImageComponent = ({
alt,
onLoad: onLoadProp,
onError: onErrorProp,
+ showControls = true,
+ showDownloadControl = true,
...props
}: ImageComponentProps) => {
const { DownloadIcon } = useIcons();
@@ -31,7 +36,8 @@ export const ImageComponent = ({
const t = useTranslations();
const hasExplicitDimensions = props.width != null || props.height != null;
- const showDownload = (imageLoaded || hasExplicitDimensions) && !imageError;
+ const canDownload = (imageLoaded || hasExplicitDimensions) && !imageError;
+ const showDownload = canDownload && showControls && showDownloadControl;
const showFallback = imageError && !hasExplicitDimensions;
// Handle images already complete before React attaches event handlers (e.g. cached or SSR hydration)
@@ -148,11 +154,14 @@ export const ImageComponent = ({
{t.imageNotAvailable}
)}
-
+ {showControls && (
+
+ )}
{showDownload && (