diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/animated-preview-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/animated-preview-auto.png
new file mode 100644
index 0000000000..996f5ec6d1
Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/animated-preview-auto.png differ
diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/default-auto.png
new file mode 100644
index 0000000000..62ea65c997
Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/default-auto.png differ
diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/error-state-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/error-state-auto.png
new file mode 100644
index 0000000000..a61b7cdf2c
Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/error-state-auto.png differ
diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/hidden-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/hidden-auto.png
new file mode 100644
index 0000000000..4f09d381a5
Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/hidden-auto.png differ
diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/loading-with-blurhash-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/loading-with-blurhash-auto.png
new file mode 100644
index 0000000000..9934c59d57
Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/loading-with-blurhash-auto.png differ
diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/loading-with-spinner-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/loading-with-spinner-auto.png
new file mode 100644
index 0000000000..6653fe161a
Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/loading-with-spinner-auto.png differ
diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/with-tooltip-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/with-tooltip-auto.png
new file mode 100644
index 0000000000..866d94ddf9
Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/with-tooltip-auto.png differ
diff --git a/packages/shared-components/package.json b/packages/shared-components/package.json
index 2cfe4a0fab..72b458751a 100644
--- a/packages/shared-components/package.json
+++ b/packages/shared-components/package.json
@@ -63,6 +63,7 @@
"linkifyjs": "4.3.2",
"lodash": "npm:lodash-es@^4.17.21",
"matrix-web-i18n": "catalog:",
+ "react-blurhash": "^0.3.0",
"react-merge-refs": "^3.0.2",
"react-resizable-panels": "^4.6.5",
"react-virtuoso": "^4.14.0",
diff --git a/packages/shared-components/src/i18n/strings/en_EN.json b/packages/shared-components/src/i18n/strings/en_EN.json
index 7cd70dbb0d..0ab6da3a94 100644
--- a/packages/shared-components/src/i18n/strings/en_EN.json
+++ b/packages/shared-components/src/i18n/strings/en_EN.json
@@ -34,6 +34,7 @@
"common": {
"attachment": "Attachment",
"encryption_enabled": "Encryption enabled",
+ "loading": "Loading…",
"options": "Options",
"preferences": "Preferences",
"state_encryption_enabled": "Experimental state encryption enabled"
diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts
index 64c504c5fb..081ccf0d83 100644
--- a/packages/shared-components/src/index.ts
+++ b/packages/shared-components/src/index.ts
@@ -17,6 +17,7 @@ export * from "./room/timeline/ReadMarker";
export * from "./room/timeline/event-tile/body/EventContentBodyView";
export * from "./room/timeline/event-tile/body/RedactedBodyView";
export * from "./room/timeline/event-tile/body/MFileBodyView";
+export * from "./room/timeline/event-tile/body/MImageBodyView";
export * from "./room/timeline/event-tile/body/MVideoBodyView";
export * from "./room/timeline/event-tile/body/TextualBodyView";
export * from "./room/timeline/event-tile/EventTileView/TileErrorView";
diff --git a/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/.gitkeep b/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/.gitkeep
deleted file mode 100644
index 8b13789179..0000000000
--- a/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/.gitkeep
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.module.css b/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.module.css
new file mode 100644
index 0000000000..05d56d3987
--- /dev/null
+++ b/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.module.css
@@ -0,0 +1,144 @@
+/*
+ * Copyright 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+.root {
+ display: flex;
+ flex-direction: column;
+ gap: var(--cpd-space-2x);
+ min-width: var(--cpd-space-0x);
+}
+
+.link {
+ display: block;
+ width: fit-content;
+ color: inherit;
+ text-decoration: none;
+}
+
+.thumbnailContainer {
+ position: relative;
+ overflow: hidden;
+ contain: paint;
+ border-radius: var(--MBody-border-radius);
+}
+
+.placeholder {
+ position: absolute;
+ inset: var(--cpd-space-0x);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background-color: var(--cpd-color-bg-canvas-default);
+ z-index: 1;
+}
+
+.placeholderBlurhash {
+ background-color: transparent;
+}
+
+.blurhash {
+ width: 100%;
+ height: 100%;
+}
+
+.blurhash > canvas {
+ width: 100%;
+ height: 100%;
+ animation: blurhashPulse 1.75s infinite cubic-bezier(0.4, 0, 0.6, 1);
+}
+
+.mediaContent {
+ position: relative;
+ max-width: 100%;
+ max-height: 100%;
+}
+
+.image {
+ display: block;
+ width: 100%;
+ height: 100%;
+}
+
+.banner {
+ position: absolute;
+ bottom: var(--cpd-space-2x);
+ left: var(--cpd-space-2x);
+ max-width: min(100%, 350px);
+ overflow: hidden;
+ padding: var(--cpd-space-1x);
+ border-radius: var(--MBody-border-radius);
+ background-color: rgb(0 0 0 / 0.6);
+ color: #fff;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font: var(--cpd-font-body-sm-regular);
+ user-select: none;
+ pointer-events: none;
+}
+
+.gifLabel {
+ position: absolute;
+ display: block;
+ top: var(--cpd-space-0x);
+ left: 14px; /* Preserve the original GIF badge offset from _MImageBody.pcss. */
+ padding: 5px; /* Preserve the original GIF badge padding from _MImageBody.pcss. */
+ border-radius: 5px; /* Preserve the original GIF badge corner radius from _MImageBody.pcss. */
+ background: rgba(0, 0, 0, 0.7);
+ border: 2px solid rgba(0, 0, 0, 0.2);
+ color: rgba(255, 255, 255, 1);
+ pointer-events: none;
+}
+
+.hiddenButton {
+ border: none;
+ width: 100%;
+ height: 100%;
+ padding: var(--cpd-space-0x);
+ inset: var(--cpd-space-0x);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ cursor: pointer;
+ background-color: var(--cpd-color-bg-subtle-secondary);
+}
+
+.hiddenButton:hover,
+.hiddenButton:focus-visible {
+ background-color: var(--cpd-color-bg-canvas-default);
+}
+
+.hiddenButtonContent {
+ display: flex;
+ color: var(--cpd-color-text-action-accent);
+}
+
+.hiddenButtonContent > svg {
+ margin-top: auto;
+ margin-bottom: auto;
+}
+
+.error {
+ display: block;
+ color: var(--cpd-color-text-critical-primary);
+}
+
+.errorIcon {
+ margin-right: var(--cpd-space-1x);
+ vertical-align: text-top;
+}
+
+@keyframes blurhashPulse {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+
+ 50% {
+ opacity: 0.65;
+ }
+}
diff --git a/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx b/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx
new file mode 100644
index 0000000000..3b6c66cf38
--- /dev/null
+++ b/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx
@@ -0,0 +1,157 @@
+/*
+ * Copyright 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+import React, { type ReactNode } from "react";
+import { expect, fn, userEvent, within } from "storybook/test";
+
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { useMockedViewModel } from "../../../../../core/viewmodel/useMockedViewModel";
+import { withViewDocs } from "../../../../../../.storybook/withViewDocs";
+import {
+ ImageBodyView,
+ ImageBodyViewPlaceholder,
+ ImageBodyViewState,
+ type ImageBodyViewActions,
+ type ImageBodyViewSnapshot,
+} from "./ImageBodyView";
+
+const imageSrc = new URL("../../../../../../static/image-body/install-spinner.png", import.meta.url).href;
+const thumbnailSrc = new URL("../../../../../../static/image-body/install-spinner.png", import.meta.url).href;
+const animatedGifSrc = new URL("../../../../../../static/image-body/install-spinner.gif", import.meta.url).href;
+const demoBlurhash = "LEHV6nWB2yk8pyo0adR*.7kCMdnj";
+const imageBodyViewStateOptions = [ImageBodyViewState.ERROR, ImageBodyViewState.HIDDEN, ImageBodyViewState.READY];
+const imageBodyViewPlaceholderOptions = [
+ ImageBodyViewPlaceholder.NONE,
+ ImageBodyViewPlaceholder.SPINNER,
+ ImageBodyViewPlaceholder.BLURHASH,
+];
+
+type ImageBodyViewProps = ImageBodyViewSnapshot &
+ ImageBodyViewActions & {
+ className?: string;
+ children?: ReactNode;
+ };
+
+const ImageBodyViewWrapperImpl = ({
+ onLinkClick,
+ onHiddenButtonClick,
+ onImageLoad,
+ onImageError,
+ className,
+ children,
+ ...snapshotProps
+}: ImageBodyViewProps): ReactNode => {
+ const vm = useMockedViewModel(snapshotProps, {
+ onLinkClick: onLinkClick ?? fn(),
+ onHiddenButtonClick: onHiddenButtonClick ?? fn(),
+ onImageLoad: onImageLoad ?? fn(),
+ onImageError: onImageError ?? fn(),
+ });
+
+ return (
+
+ {children}
+
+ );
+};
+
+const ImageBodyViewWrapper = withViewDocs(ImageBodyViewWrapperImpl, ImageBodyView);
+
+const meta = {
+ title: "MessageBody/ImageBodyView",
+ component: ImageBodyViewWrapper,
+ tags: ["autodocs"],
+ argTypes: {
+ state: {
+ options: imageBodyViewStateOptions,
+ control: { type: "select" },
+ },
+ placeholder: {
+ options: imageBodyViewPlaceholderOptions,
+ control: { type: "select" },
+ },
+ className: { control: "text" },
+ },
+ args: {
+ state: ImageBodyViewState.READY,
+ alt: "Element logo",
+ hiddenButtonLabel: "Show image",
+ errorLabel: "Unable to show image due to error",
+ src: imageSrc,
+ thumbnailSrc,
+ showAnimatedContentOnHover: false,
+ placeholder: ImageBodyViewPlaceholder.NONE,
+ blurhash: demoBlurhash,
+ maxWidth: 320,
+ maxHeight: 320,
+ aspectRatio: "1 / 1",
+ isSvg: false,
+ gifLabel: undefined,
+ bannerLabel: "install-spinner.png",
+ tooltipLabel: undefined,
+ linkUrl: imageSrc,
+ linkTarget: undefined,
+ className: undefined,
+ children:
File body slot
,
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const Hidden: Story = {
+ args: {
+ state: ImageBodyViewState.HIDDEN,
+ linkUrl: undefined,
+ tooltipLabel: undefined,
+ },
+};
+
+export const LoadingWithSpinner: Story = {
+ args: {
+ placeholder: ImageBodyViewPlaceholder.SPINNER,
+ },
+};
+
+export const LoadingWithBlurhash: Story = {
+ args: {
+ placeholder: ImageBodyViewPlaceholder.BLURHASH,
+ },
+};
+
+export const AnimatedPreview: Story = {
+ args: {
+ src: animatedGifSrc,
+ thumbnailSrc,
+ linkUrl: animatedGifSrc,
+ showAnimatedContentOnHover: true,
+ gifLabel: "GIF",
+ },
+};
+
+export const ErrorState: Story = {
+ args: {
+ state: ImageBodyViewState.ERROR,
+ linkUrl: undefined,
+ children: undefined,
+ },
+};
+
+export const WithTooltip: Story = {
+ args: {
+ tooltipLabel: "Tooltip image name",
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await userEvent.hover(canvas.getByRole("img", { name: "Element logo" }));
+ await expect(
+ within(canvasElement.ownerDocument.body).findByText("Tooltip image name"),
+ ).resolves.toBeInTheDocument();
+ },
+};
diff --git a/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.test.tsx b/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.test.tsx
new file mode 100644
index 0000000000..2eaeeba41f
--- /dev/null
+++ b/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.test.tsx
@@ -0,0 +1,181 @@
+/*
+ * Copyright 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+import React from "react";
+import { composeStories } from "@storybook/react-vite";
+import { fireEvent, render, screen } from "@test-utils";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+
+import { MockViewModel } from "../../../../../core/viewmodel/MockViewModel";
+import * as stories from "./ImageBodyView.stories";
+import {
+ ImageBodyView,
+ ImageBodyViewPlaceholder,
+ ImageBodyViewState,
+ type ImageBodyViewActions,
+ type ImageBodyViewSnapshot,
+} from "./ImageBodyView";
+
+const { Default, Hidden, LoadingWithSpinner, LoadingWithBlurhash, AnimatedPreview, ErrorState } =
+ composeStories(stories);
+
+class TestImageBodyViewModel extends MockViewModel implements ImageBodyViewActions {
+ public onLinkClick?: ImageBodyViewActions["onLinkClick"];
+ public onHiddenButtonClick?: ImageBodyViewActions["onHiddenButtonClick"];
+ public onImageLoad?: ImageBodyViewActions["onImageLoad"];
+ public onImageError?: ImageBodyViewActions["onImageError"];
+
+ public constructor(snapshot: ImageBodyViewSnapshot, actions: ImageBodyViewActions = {}) {
+ super(snapshot);
+ this.onLinkClick = actions.onLinkClick;
+ this.onHiddenButtonClick = actions.onHiddenButtonClick;
+ this.onImageLoad = actions.onImageLoad;
+ this.onImageError = actions.onImageError;
+ }
+}
+
+describe("ImageBodyView", () => {
+ it.each([
+ ["default", Default],
+ ["hidden", Hidden],
+ ["loading-with-spinner", LoadingWithSpinner],
+ ["loading-with-blurhash", LoadingWithBlurhash],
+ ["animated-preview", AnimatedPreview],
+ ["error", ErrorState],
+ ])("matches snapshot for %s story", (_name, Story) => {
+ const { container } = render();
+ expect(container).toMatchSnapshot();
+ });
+
+ it("renders the hidden preview button and invokes the click handler", async () => {
+ const user = userEvent.setup();
+ const onHiddenButtonClick = vi.fn();
+ const vm = new TestImageBodyViewModel(
+ {
+ state: ImageBodyViewState.HIDDEN,
+ hiddenButtonLabel: "Show image",
+ maxWidth: 320,
+ maxHeight: 240,
+ aspectRatio: "4 / 3",
+ },
+ { onHiddenButtonClick },
+ );
+
+ render();
+
+ await user.click(screen.getByRole("button", { name: "Show image" }));
+ expect(onHiddenButtonClick).toHaveBeenCalledTimes(1);
+ });
+
+ it("renders an error label when the media cannot be displayed", () => {
+ const vm = new TestImageBodyViewModel({
+ state: ImageBodyViewState.ERROR,
+ errorLabel: "Error decrypting image",
+ });
+
+ render();
+
+ expect(screen.getByText("Error decrypting image")).toBeInTheDocument();
+ expect(screen.queryByRole("img")).not.toBeInTheDocument();
+ });
+
+ it("renders a link wrapper and forwards the click handler", () => {
+ const onLinkClick = vi.fn();
+ const vm = new TestImageBodyViewModel(
+ {
+ state: ImageBodyViewState.READY,
+ alt: "Linked image",
+ src: "https://example.org/full.png",
+ thumbnailSrc: "https://example.org/thumb.png",
+ linkUrl: "https://example.org/full.png",
+ linkTarget: "_blank",
+ maxWidth: 320,
+ maxHeight: 240,
+ aspectRatio: "4 / 3",
+ },
+ { onLinkClick },
+ );
+
+ render();
+
+ const link = screen.getByRole("link");
+ expect(link).toHaveAttribute("href", "https://example.org/full.png");
+ expect(link).toHaveAttribute("target", "_blank");
+ expect(link).toHaveAttribute("rel", "noreferrer noopener");
+
+ fireEvent.click(link);
+ expect(onLinkClick).toHaveBeenCalledTimes(1);
+ });
+
+ it("swaps to the full source on hover for animated previews", async () => {
+ const user = userEvent.setup();
+ const vm = new TestImageBodyViewModel({
+ state: ImageBodyViewState.READY,
+ alt: "Animated image",
+ src: "https://example.org/full.gif",
+ thumbnailSrc: "https://example.org/thumb.png",
+ showAnimatedContentOnHover: true,
+ gifLabel: "GIF",
+ maxWidth: 320,
+ maxHeight: 240,
+ aspectRatio: "4 / 3",
+ });
+
+ render();
+
+ const image = screen.getByRole("img", { name: "Animated image" }) as HTMLImageElement;
+ expect(image).toHaveAttribute("src", "https://example.org/thumb.png");
+ expect(screen.getByText("GIF")).toBeInTheDocument();
+
+ await user.hover(image);
+ expect(image).toHaveAttribute("src", "https://example.org/full.gif");
+ expect(screen.queryByText("GIF")).not.toBeInTheDocument();
+ });
+
+ it("renders the configured placeholder state", () => {
+ const vm = new TestImageBodyViewModel({
+ state: ImageBodyViewState.READY,
+ alt: "Loading image",
+ src: "https://example.org/full.png",
+ placeholder: ImageBodyViewPlaceholder.SPINNER,
+ maxWidth: 320,
+ maxHeight: 240,
+ aspectRatio: "4 / 3",
+ });
+
+ render();
+
+ expect(screen.getByRole("progressbar")).toBeInTheDocument();
+ });
+
+ it("invokes image load and error handlers", () => {
+ const onImageLoad = vi.fn();
+ const onImageError = vi.fn();
+ const vm = new TestImageBodyViewModel(
+ {
+ state: ImageBodyViewState.READY,
+ alt: "Loaded image",
+ src: "https://example.org/full.png",
+ thumbnailSrc: "https://example.org/thumb.png",
+ maxWidth: 320,
+ maxHeight: 240,
+ aspectRatio: "4 / 3",
+ },
+ { onImageLoad, onImageError },
+ );
+
+ render();
+
+ const image = screen.getByRole("img", { name: "Loaded image" });
+ fireEvent.load(image);
+ fireEvent.error(image);
+
+ expect(onImageLoad).toHaveBeenCalledTimes(1);
+ expect(onImageError).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.tsx b/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.tsx
new file mode 100644
index 0000000000..7a4ea96acb
--- /dev/null
+++ b/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.tsx
@@ -0,0 +1,354 @@
+/*
+ * Copyright 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+import React, {
+ type CSSProperties,
+ type HTMLAttributeAnchorTarget,
+ type JSX,
+ type MouseEventHandler,
+ type PropsWithChildren,
+ type ReactEventHandler,
+ useState,
+} from "react";
+import classNames from "classnames";
+import { Blurhash } from "react-blurhash";
+import { ImageErrorIcon, VisibilityOnIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
+import { InlineSpinner, Tooltip } from "@vector-im/compound-web";
+
+import { useI18n } from "../../../../../core/i18n/i18nContext";
+import { type ViewModel, useViewModel } from "../../../../../core/viewmodel";
+import styles from "./ImageBodyView.module.css";
+
+/**
+ * High-level rendering state for the shared image body view.
+ */
+export const enum ImageBodyViewState {
+ ERROR = "ERROR",
+ HIDDEN = "HIDDEN",
+ READY = "READY",
+}
+
+/**
+ * Placeholder variant shown over the media frame while the image is still settling.
+ */
+export const enum ImageBodyViewPlaceholder {
+ NONE = "NONE",
+ SPINNER = "SPINNER",
+ BLURHASH = "BLURHASH",
+}
+
+export interface ImageBodyViewSnapshot {
+ /**
+ * Controls whether the component renders an error state, a hidden-preview state,
+ * or a visible image frame.
+ */
+ state: ImageBodyViewState;
+ /**
+ * Image alt text.
+ */
+ alt?: string;
+ /**
+ * Label shown when media processing fails.
+ */
+ errorLabel?: string;
+ /**
+ * Label used by the hidden-media reveal button.
+ */
+ hiddenButtonLabel?: string;
+ /**
+ * Full-resolution image source.
+ */
+ src?: string;
+ /**
+ * Thumbnail/static preview image source.
+ * Falls back to `src` when omitted.
+ */
+ thumbnailSrc?: string;
+ /**
+ * Whether hovering or focusing the link should swap to the full-resolution image.
+ */
+ showAnimatedContentOnHover?: boolean;
+ /**
+ * Which placeholder to render over the image frame.
+ */
+ placeholder?: ImageBodyViewPlaceholder;
+ /**
+ * Blurhash string used when `placeholder` is `BLURHASH`.
+ */
+ blurhash?: string;
+ /**
+ * Maximum rendered width for the media frame.
+ */
+ maxWidth?: number;
+ /**
+ * Maximum rendered height for the media frame.
+ */
+ maxHeight?: number;
+ /**
+ * Aspect ratio reserved for the media frame.
+ */
+ aspectRatio?: CSSProperties["aspectRatio"];
+ /**
+ * Whether the displayed image is an SVG and should therefore use explicit width sizing.
+ */
+ isSvg?: boolean;
+ /**
+ * Optional badge shown for animated images when not hovered/focused.
+ */
+ gifLabel?: string;
+ /**
+ * Optional overlay banner shown while hovered/focused.
+ */
+ bannerLabel?: string;
+ /**
+ * Optional tooltip shown on the media frame.
+ */
+ tooltipLabel?: string;
+ /**
+ * Optional link target for the media frame.
+ */
+ linkUrl?: string;
+ /**
+ * Optional anchor target applied when `linkUrl` is provided.
+ */
+ linkTarget?: HTMLAttributeAnchorTarget;
+}
+
+export interface ImageBodyViewActions {
+ /**
+ * Invoked when the linked image is activated.
+ */
+ onLinkClick?: MouseEventHandler;
+ /**
+ * Invoked when the user chooses to reveal hidden media.
+ */
+ onHiddenButtonClick?: MouseEventHandler;
+ /**
+ * Invoked when the visible image loads.
+ */
+ onImageLoad?: ReactEventHandler;
+ /**
+ * Invoked when the visible image fails to load.
+ */
+ onImageError?: ReactEventHandler;
+}
+
+export type ImageBodyViewModel = ViewModel;
+
+interface ImageBodyViewProps {
+ /**
+ * The view model for the component.
+ */
+ vm: ImageBodyViewModel;
+ /**
+ * Optional host CSS class.
+ */
+ className?: string;
+ /**
+ * Optional supplemental content rendered after the media frame.
+ */
+ children?: PropsWithChildren["children"];
+}
+
+function renderPlaceholder({
+ placeholder,
+ blurhash,
+ maxWidth,
+ maxHeight,
+ loadingLabel,
+}: Pick & {
+ loadingLabel: string;
+}): JSX.Element | null {
+ switch (placeholder) {
+ case ImageBodyViewPlaceholder.BLURHASH:
+ if (!blurhash) {
+ return ;
+ }
+
+ return (
+
+ );
+
+ case ImageBodyViewPlaceholder.SPINNER:
+ return ;
+
+ case ImageBodyViewPlaceholder.NONE:
+ default:
+ return null;
+ }
+}
+
+/**
+ * Renders the body of an image message with ready, hidden, and error states.
+ *
+ * The media frame supports thumbnail fallbacks, optional loading placeholders,
+ * animated-content preview on hover/focus, and optional tooltip/banner labels.
+ * Supplemental content such as a file body row can be rendered after the image
+ * through `children`.
+ *
+ * @example
+ * ```tsx
+ *
+ * File body slot
+ *
+ * ```
+ */
+export function ImageBodyView({ vm, className, children }: Readonly): JSX.Element {
+ const { translate: _t } = useI18n();
+ const {
+ state,
+ alt,
+ errorLabel,
+ hiddenButtonLabel,
+ src,
+ thumbnailSrc,
+ showAnimatedContentOnHover,
+ placeholder = ImageBodyViewPlaceholder.NONE,
+ blurhash,
+ maxWidth,
+ maxHeight,
+ aspectRatio,
+ isSvg,
+ gifLabel,
+ bannerLabel,
+ tooltipLabel,
+ linkUrl,
+ linkTarget,
+ } = useViewModel(vm);
+
+ const [hover, setHover] = useState(false);
+ const [focus, setFocus] = useState(false);
+ const hoverOrFocus = hover || focus;
+
+ const rootClassName = classNames(className, styles.root);
+
+ if (state === ImageBodyViewState.ERROR) {
+ return (
+
+
+ {errorLabel}
+
+ );
+ }
+
+ const resolvedThumbnailSrc = thumbnailSrc ?? src;
+ const resolvedImageSrc = hoverOrFocus && showAnimatedContentOnHover && src ? src : resolvedThumbnailSrc;
+
+ // Reserve the media box on the container itself so the timeline doesn't jump
+ // while the image element or loading state is still settling.
+ const resolvedWidth = maxWidth === undefined ? undefined : `min(100%, ${maxWidth}px)`;
+ const containerStyle: CSSProperties = {
+ width: resolvedWidth,
+ maxWidth,
+ maxHeight,
+ aspectRatio,
+ };
+ const mediaStyle: CSSProperties | undefined = isSvg
+ ? {
+ width: resolvedWidth,
+ maxWidth,
+ maxHeight,
+ }
+ : undefined;
+
+ const placeholderNode = renderPlaceholder({
+ placeholder,
+ blurhash,
+ maxWidth,
+ maxHeight,
+ loadingLabel: _t("common|loading"),
+ });
+ const showPlaceholder = placeholderNode !== null;
+
+ const media =
+ state === ImageBodyViewState.HIDDEN ? (
+
+
+
+ ) : resolvedImageSrc ? (
+
setHover(true)}
+ onMouseLeave={(): void => setHover(false)}
+ />
+ ) : null;
+
+ const banner =
+ state === ImageBodyViewState.READY && bannerLabel && hoverOrFocus ? (
+ {bannerLabel}
+ ) : null;
+
+ const gifBadge =
+ state === ImageBodyViewState.READY && gifLabel && !hoverOrFocus ? (
+ {gifLabel}
+ ) : null;
+
+ let frame = (
+
+ {showPlaceholder && (
+
+ {placeholderNode}
+
+ )}
+
+
+ {media}
+ {gifBadge}
+ {banner}
+
+
+ );
+
+ if (tooltipLabel) {
+ frame = (
+
+ {frame}
+
+ );
+ }
+
+ if (state === ImageBodyViewState.READY && linkUrl) {
+ frame = (
+ setFocus(true)}
+ onBlur={(): void => setFocus(false)}
+ >
+ {frame}
+
+ );
+ }
+
+ return (
+
+ {frame}
+ {children}
+
+ );
+}
diff --git a/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/__snapshots__/ImageBodyView.test.tsx.snap b/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/__snapshots__/ImageBodyView.test.tsx.snap
new file mode 100644
index 0000000000..e01e97e094
--- /dev/null
+++ b/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/__snapshots__/ImageBodyView.test.tsx.snap
@@ -0,0 +1,238 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`ImageBodyView > matches snapshot for animated-preview story 1`] = `
+
+`;
+
+exports[`ImageBodyView > matches snapshot for default story 1`] = `
+
+`;
+
+exports[`ImageBodyView > matches snapshot for error story 1`] = `
+
+
+
+ Unable to show image due to error
+
+
+`;
+
+exports[`ImageBodyView > matches snapshot for hidden story 1`] = `
+
+`;
+
+exports[`ImageBodyView > matches snapshot for loading-with-blurhash story 1`] = `
+
+`;
+
+exports[`ImageBodyView > matches snapshot for loading-with-spinner story 1`] = `
+
+`;
diff --git a/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/index.tsx b/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/index.tsx
new file mode 100644
index 0000000000..a71a80c047
--- /dev/null
+++ b/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/index.tsx
@@ -0,0 +1,15 @@
+/*
+ * Copyright 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+export {
+ ImageBodyView,
+ ImageBodyViewPlaceholder,
+ ImageBodyViewState,
+ type ImageBodyViewActions,
+ type ImageBodyViewModel,
+ type ImageBodyViewSnapshot,
+} from "./ImageBodyView";
diff --git a/packages/shared-components/static/image-body/install-spinner.gif b/packages/shared-components/static/image-body/install-spinner.gif
new file mode 100644
index 0000000000..ce6c17844a
Binary files /dev/null and b/packages/shared-components/static/image-body/install-spinner.gif differ
diff --git a/packages/shared-components/static/image-body/install-spinner.png b/packages/shared-components/static/image-body/install-spinner.png
new file mode 100644
index 0000000000..75c3f48ad8
Binary files /dev/null and b/packages/shared-components/static/image-body/install-spinner.png differ
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b76736e75d..4f9f199841 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1055,6 +1055,9 @@ importers:
matrix-web-i18n:
specifier: 'catalog:'
version: 3.6.0
+ react-blurhash:
+ specifier: ^0.3.0
+ version: 0.3.0(patch_hash=58bc7f075478017ce27bcc252e8509876390db106246bd5b0a7446642cc4b505)(blurhash@2.0.5)(react@19.2.5)
react-merge-refs:
specifier: ^3.0.2
version: 3.0.2(react@19.2.5)