diff --git a/__tests__/e2e/theme-image/image.test.ts b/__tests__/e2e/theme-image/image.test.ts
new file mode 100644
index 000000000..1822edaff
--- /dev/null
+++ b/__tests__/e2e/theme-image/image.test.ts
@@ -0,0 +1,38 @@
+describe('themeable image alt overrides', () => {
+ beforeEach(async () => {
+ await goto('/theme-image/')
+ })
+
+ test.each(['light', 'dark'])('uses the alt prop in %s mode', async (mode) => {
+ await page.evaluate((mode) => {
+ document.documentElement.classList.toggle('dark', mode === 'dark')
+ }, mode)
+
+ expect(
+ await page.locator('.image-override img:visible').getAttribute('alt')
+ ).toBe('Override description')
+ expect(
+ await page.locator('.image-decorative img:visible').getAttribute('alt')
+ ).toBe('')
+ expect(
+ await page.locator('.image-fallback img:visible').getAttribute('alt')
+ ).toBe('Image description')
+ })
+
+ test('updates both images when the alt prop changes', async () => {
+ const descriptions = () =>
+ page
+ .locator('.image-override img')
+ .evaluateAll((images) =>
+ images.map((image) => image.getAttribute('alt'))
+ )
+
+ await page.getByRole('button', { name: 'Update description' }).click()
+ await expect
+ .poll(descriptions)
+ .toEqual(['Updated description', 'Updated description'])
+
+ await page.getByRole('button', { name: 'Clear description' }).click()
+ await expect.poll(descriptions).toEqual(['', ''])
+ })
+})
diff --git a/__tests__/e2e/theme-image/index.md b/__tests__/e2e/theme-image/index.md
new file mode 100644
index 000000000..c7ea85f4c
--- /dev/null
+++ b/__tests__/e2e/theme-image/index.md
@@ -0,0 +1,28 @@
+
+
+# Themeable image descriptions
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/__tests__/unit/client/theme-default/components/VPImage.test.ts b/__tests__/unit/client/theme-default/components/VPImage.test.ts
new file mode 100644
index 000000000..66719d376
--- /dev/null
+++ b/__tests__/unit/client/theme-default/components/VPImage.test.ts
@@ -0,0 +1,41 @@
+// @ts-expect-error the unit typecheck uses tsc; Vite compiles this SFC at runtime
+import VPImage from 'client/theme-default/components/VPImage.vue'
+import { createSSRApp } from 'vue'
+import { renderToString } from 'vue/server-renderer'
+
+vi.mock('vitepress', () => ({ withBase: (path: string) => path }))
+
+const images = [
+ { name: 'string', image: 'logo.svg', fallback: '', count: 1 },
+ {
+ name: 'single source',
+ image: { src: 'logo.svg', alt: 'Image description' },
+ fallback: 'Image description',
+ count: 1
+ },
+ {
+ name: 'themed sources',
+ image: { light: 'light.svg', dark: 'dark.svg', alt: 'Image description' },
+ fallback: 'Image description',
+ count: 2
+ },
+ {
+ name: 'themed sources without alt',
+ image: { light: 'light.svg', dark: 'dark.svg' },
+ fallback: '',
+ count: 2
+ }
+]
+
+describe.each(images)('VPImage with $name', ({ image, fallback, count }) => {
+ test.each(['Override description', '', undefined])(
+ 'respects the alt prop %j',
+ async (alt) => {
+ const html = await renderToString(createSSRApp(VPImage, { image, alt }))
+ const descriptions = [...html.matchAll(/ alt(?:="([^"]*)")?/g)].map(
+ ([, value]) => value ?? ''
+ )
+ expect(descriptions).toEqual(Array(count).fill(alt ?? fallback))
+ }
+ )
+})
diff --git a/src/client/theme-default/components/VPImage.vue b/src/client/theme-default/components/VPImage.vue
index e2d606210..fb04af238 100644
--- a/src/client/theme-default/components/VPImage.vue
+++ b/src/client/theme-default/components/VPImage.vue
@@ -23,13 +23,13 @@ defineOptions({ inheritAttrs: false })