skip to content
Strips of photographic film being inspected on a lightbox.

Painless PhotoSwipe Lightboxes for Astro MDX Blog Posts

Data visualizations and maps are an essential part of data-driven blog posts, but small images can make extracting insights a challenge. A lightbox launches an image in a new, full-screen view that allows for zooming and panning, which is especially helpful when inspecting a detailed graphic on a small screen. In this post we’ll walk through building a lightbox for Astro MDX images that uses PhotoSwipe, a popular JavaScript lightbox library.

Astro makes this process relatively painless— its MDX plugin integrates seamlessly with the framework’s build-time image optimization, providing hooks that allow you to replace the default <img> tags with a custom component without losing the sharp-based image optimization.

In this case, we can use a custom component to wrap the image in a link and add the necessary PhotoSwipe attributes. The build-time image handling even provides pre-calculated width and height attributes that we can forward to PhotoSwipe.

The end result is a performant lightbox/gallery component that I don’t need to think about while authoring content, even when inserting screenshots or generating charts in “legacy” formats like png.

Why PhotoSwipe?

PhotoSwipe is a solid choice for Astro projects because:

  • Pure JavaScript - No framework dependencies, works with any setup
  • Touch-friendly - Supports pinch-to-zoom, swipe gestures
  • Accessible - Keyboard navigation, ARIA attributes
  • Lightweight - ~60KB bundled, tree-shakeable
  • Maintained - Active development, good documentation
  • Popular - widespread usage means that LLMs are familiar with the codebase

The Integration Challenge

Astro’s image optimization and MDX content handling raise some integration issues:

  1. By default, MDX images are replaced by optimized <Image> components that are rendered as <img> tags with various attributes like width, height, loading, etc., and we don’t want to lose these optimizations.
  2. Astro optimizes images - Local images go through Astro’s asset pipeline, not only optimizing to modern formats but also providing cache-busting URLs. Therefore we don’t know the final URL of the source image until it’s optimized at build time.
  3. Image dimensions are required - PhotoSwipe needs data-pswp-width and data-pswp-height attributes on gallery items. Fortunately, build time optimization also provides us with width and height.
  4. When authoring MDX content, we generally use markdown or basic HTML image tags, not the actual <Image> component. Astro performs this substitution for us implicitly. Fortunately, we can define a custom component that the optimized image can slot into.

Architecture Overview

The solution involves three custom components:

  1. LightboxImage.astro - An MDX component that replaces <img> tags. Notably, it receives props from the image optimization pipeline.
  2. PhotoSwipe initialization - A script in the blog layout that sets up the lightbox
  3. Cover image integration - Making hero images part of the same gallery

And here’s a diagram of how these components fit into Astro’s content processing pipelines:

Diagram

The LightboxImage Component

This component intercepts all images in MDX content and wraps them with PhotoSwipe-compatible markup:

src/components/LightboxImage.astro
---
import type { ImageMetadata } from "astro";
import { Image } from "astro:assets";
interface Props {
src: string | ImageMetadata;
alt: string;
width?: number;
height?: number;
}
const { src, alt, width, height } = Astro.props;
const isLocalImage = typeof src !== "string";
const imageWidth = isLocalImage ? (src as ImageMetadata).width : width;
const imageHeight = isLocalImage ? (src as ImageMetadata).height : height;
// Only apply lightbox to images >= 200px wide
const shouldHaveLightbox = !imageWidth || imageWidth >= 200;
const fullSizeUrl = isLocalImage
? (src as ImageMetadata).src
: (src as string);
---
{shouldHaveLightbox ? (
<a
href={fullSizeUrl}
data-pswp-width={imageWidth}
data-pswp-height={imageHeight}
class="lightbox-trigger block"
>
<Image
src={src as ImageMetadata}
alt={alt}
class="cursor-zoom-in"
loading="lazy"
/>
</a>
) : (
<Image src={src as ImageMetadata} alt={alt} loading="lazy" />
)}

Key points:

  • Astro’s <Image> component handles optimization (WebP conversion, responsive sizes)
  • ImageMetadata provides width/height from the asset pipeline. These props are provided to any component that replaces the img tag in an MDX file.

Equipped with these props, we create the following:

  • Anchor wrapper with href pointing to the full-size image
  • data-pswp- attributes* tell PhotoSwipe the image dimensions

Wiring Up MDX

In the page that renders blog posts, pass the component to MDX:

src/pages/posts/[...slug].astro
---
import LightboxImage from "@/components/LightboxImage.astro";
---
<Content components={{ img: LightboxImage }} />

This replaces every <img> in your MDX content with the lightbox-enabled version. The key (relatively undocumented) insight here is that any MDX component that replaces the img tag in an MDX file will receive the same props as the original img tag.

PhotoSwipe Initialization

In the blog post layout, initialize PhotoSwipe after the page loads:

src/layouts/BlogPost.astro
<article>
<!-- Your content here -->
</article>
<script>
import PhotoSwipeLightbox from "photoswipe/lightbox";
import PhotoSwipe from "photoswipe";
import "photoswipe/style.css";
let lightbox: PhotoSwipeLightbox | null = null;
function initPhotoSwipe() {
// Destroy previous instance on navigation
if (lightbox) {
lightbox.destroy();
lightbox = null;
}
lightbox = new PhotoSwipeLightbox({
gallery: "article",
children: ".lightbox-trigger",
pswpModule: PhotoSwipe,
});
lightbox.init();
}
// Initialize on load and Astro view transitions
initPhotoSwipe();
document.addEventListener("astro:page-load", initPhotoSwipe);
</script>

The astro:page-load event ensures the lightbox reinitializes after View Transitions navigation. I’m not currently using View Transitions, but it’s something that I have on my radar.

If your cover image also appears in the article content, you may want to de-duplicate the gallery. Here’s an enhanced initialization that tracks seen images:

function initPhotoSwipe() {
if (lightbox) {
lightbox.destroy();
lightbox = null;
}
const article = document.querySelector("article");
if (!article) return;
const allTriggers = article.querySelectorAll(".lightbox-trigger");
const seenSrcs = new Set();
const srcToIndex = new Map();
let index = 0;
allTriggers.forEach((trigger) => {
const href = trigger.getAttribute("href");
if (href && !seenSrcs.has(href)) {
srcToIndex.set(href, index++);
seenSrcs.add(href);
} else if (href) {
// Mark duplicates
trigger.classList.add("lightbox-duplicate");
trigger.setAttribute(
"data-pswp-original-index",
String(srcToIndex.get(href))
);
}
});
lightbox = new PhotoSwipeLightbox({
gallery: "article",
children: ".lightbox-trigger:not(.lightbox-duplicate)",
pswpModule: PhotoSwipe,
});
lightbox.init();
// Duplicates still open lightbox at correct position
article.querySelectorAll(".lightbox-duplicate").forEach((dup) => {
dup.addEventListener("click", (e) => {
e.preventDefault();
const idx = parseInt(
dup.getAttribute("data-pswp-original-index") || "0"
);
lightbox?.loadAndOpen(idx);
});
});
}

This ensures each unique image appears once in the gallery, while clicking any instance opens the lightbox.

Testing with Playwright

Testing lightbox functionality requires handling async interactions. Here’s a focused test suite:

tests/lightbox.spec.ts
import { test, expect } from "@playwright/test";
test.describe("Lightbox", () => {
const testPostUrl = "/posts/your-post-with-images/";
test("opens when clicking an image", async ({ page }) => {
await page.goto(testPostUrl);
const image = page.locator(".lightbox-trigger").first();
await image.click();
// PhotoSwipe adds .pswp when open
await expect(page.locator(".pswp")).toBeVisible();
});
test("has close button when open", async ({ page }) => {
await page.goto(testPostUrl);
await page.locator(".lightbox-trigger").first().click();
await expect(page.locator(".pswp.pswp--open")).toBeVisible();
// Verify UI controls exist
await expect(
page.locator(".pswp__button--close")
).toBeVisible();
});
test("images use Astro optimization", async ({ page }) => {
await page.goto(testPostUrl);
const image = page.locator(".lightbox-trigger img").first();
const src = await image.getAttribute("src");
// Astro-processed images have /_astro/ or /_image/ paths
const isOptimized = src?.includes("/_astro/") ||
(src?.includes("/_image/") && src?.includes("webp"));
expect(isOptimized).toBe(true);
});
test("shows zoom cursor on hover", async ({ page }) => {
await page.goto(testPostUrl);
const image = page.locator(".lightbox-trigger img").first();
const cursor = await image.evaluate(
(el) => getComputedStyle(el).cursor
);
expect(cursor).toBe("zoom-in");
});
});

Note: Testing close behavior can be flaky due to PhotoSwipe’s animations. In practice, verifying that the lightbox opens and UI elements exist is sufficient - the close functionality is PhotoSwipe’s well-tested internal behavior.

Performance Considerations

This approach maintains decent performance:

  1. Lazy loading - Images load as they enter the viewport
  2. Optimized formats - Astro converts to WebP/AVIF automatically
  3. No runtime overhead - Dimensions are known at build time

There is some room for improvement. Right now I’m only generating a single optimized image file. Astro does support responsive images via global configuration, but I’m not using that feature yet (and am not yet familiar with responsive images in general).

Also, I’m initializing and loading the lightbox gallery component on every blog post, even ones that don’t have any images. I could look into conditionally importing the photoswipe library only on pages that have images, but the library is small enough (16kb gzipped) that I’m not concerned.

Conclusion

Integrating PhotoSwipe with Astro’s MDX and image optimization requires coordinating three systems: the asset pipeline, MDX component replacement, and client-side initialization. The key insight is that Astro’s ImageMetadata provides the dimensions PhotoSwipe needs, eliminating runtime dimension detection, and also provides a link to the optimized image.

The result is a high-quality lightbox that I don’t need to spend more time thinking about— so I can get back to writing visualization-heavy posts.