So you have an Astro site and you want to show a pile of images. You could certainly just throw in a component and call it done:

  1. Astro LightGallery
  2. Astro Photo Grid
  3. Astro Pandabox

Unfortunately, none of these “solutions” truly leverage what Astro has to offer: namely view transitions.

Astro LightGalleryAstro Photo GridAstro Pandabox
Has dependenciesYesNoYes
Uses view transitionsNoNoNo
Responsive imagesYesYesYes
A URL per photoNoNoNo

That last row decides everything, and it explains why none of them can do the transition. A lightbox opening in javascript holds 1 URL for the whole gallery. You cannot link to a photograph. You cannot get back to the grid with the back button. And since the browser never navigates, it has no “before” and “after” to animate between.

Let me build it properly. No dependencies.

One Photo, One Route

Start with the data. A content collection holds the galleries, each with a title and a list of photographs.

const galleries = defineCollection({
	type: "data",
	schema: z.object({
		title: z.string(),
		photos: z.array(z.object({image: z.string(), caption: z.string().optional()}))
	})
});

Then give every photograph a page of its own. A rest parameter in the filename lets a slug keep its slashes, so nature/berries.jpg gets served at /photography/nature/berries:

---
// src/pages/photography/[...photo].astro
export const getStaticPaths = async function () {
	const galleries = await getCollection("galleries");
	return galleries.flatMap(function eachGallery(gallery) {
		return gallery.data.photos.map(function eachPhoto(photo, index) {
			return {
				params: {photo: getPhotoSlug(photo)},
				props: {photo, gallery, position: index + 1}
			};
		});
	});
};
---

Real URLs now. Share one, bookmark one, open one in a new tab. Works. Works with javascript off, too. That alone beats what the 3 packages give you.

The Overlay

Here comes the part people find surprising. The “lightbox” holds no state and needs no component. It uses the photograph’s page, which renders the whole gallery and then lays the photograph over it.

<Layout>
	<PhotographyGalleries activePhoto={getPhotoSlug(photo)}/>

	<section class="gallery-active-photo">
		<a href="/photography" class="close">Close</a>
		<figure>
			<GalleryPhoto photo={photo} variant="full"/>
		</figure>
	</section>
</Layout>
.gallery-active-photo {
	position: fixed;
	inset: 0;
	z-index: 30;
	display: grid;
	place-items: center;
	background-color: rgb(0 0 0 / 0.94);
}

Closing it means following a link back to the gallery. Escape and the arrow keys add a nicety on top, about 15 lines, and everything still works without them.

The Transition

Both pages render the same grid, so the same photograph exists in both documents. Tell the browser they hold the same thing and it will animate one into the other.

The opt-in takes 1 at-rule, and it costs nothing at runtime:

@media (prefers-reduced-motion: no-preference) {
	@view-transition { navigation: auto; }
}

Then name the 2 appearances. A CSS identifier cannot hold a slash and a slug holds several, so fold the separators. And prefix it, because an identifier cannot start with a digit and a gallery named for a year would hand you one that does:

/** Anything in a slug that a CSS identifier may not hold. */
const NON_IDENTIFIER = /[^a-z0-9]+/gi;

/**
 * The name the browser pairs a photograph's 2 appearances by.
 *
 * @param {string} slug what addresses the photograph
 * @returns {string} the identifier both appearances carry
 */
const getTransitionName = function (slug) {
	return `photo-${slug.replace(NON_IDENTIFIER, "-")}`;
};

Put it on the grid cell and on the full-size frame. Done. The thumbnail grows out of the collage on the way in and shrinks back into the cell it came from on the way back. The reverse comes free, since the same 2 names swap roles.

4 Silent Failures

I got all 4 wrong before I got them right, and every one fails without a word.

A name declared twice aborts the whole transition. Not “animates oddly”. Aborts. The photo page renders the collage behind the overlay, so the active photograph appears on that page twice. Pass the active slug down and leave that thumbnail unnamed:

<a
	href={`/photography/${slug}`}
	style={slug === activePhoto ? undefined : `view-transition-name: ${getTransitionName(slug)}`}
>

A name animates the element’s box. A centred grid item’s box covers the whole grid area, so naming the <figure> grew my thumbnail to the width of the screen on its way in. Wrap the picture in a fit-content frame and name that instead.

<Picture> drops attributes it does not recognise. A style set on Astro’s image component never reaches the <img>. No warning. Just gone. Put the name on an element you own. In a collage the link covers the cell, and the picture fills it exactly.

Snapshots paint in capture order. The overlay comes after the grid in the document, so its backdrop faded in over the travelling photograph, which went almost black halfway across before coming back. z-index on the group sorts it:

::view-transition-group(.photograph) { z-index: 1; }

That .photograph holds a view-transition-class, which lets you style 25 names without writing 25 selectors. While you sit in there, give both snapshots object-fit: cover. A thumbnail shows a crop and the full view shows the whole picture, and without it one of them stretches the whole way across.

The Result

No dependencies. A URL per photograph. A back button that does the right thing. Works with javascript disabled. Degrades to a plain navigation in a browser without view transitions, which those browsers did anyway.

And none of the packages offer this for a reason that has nothing to do with their authors missing a trick. A lightbox built as a component has nowhere to put the second URL. The transition falls out of getting the routing right. You cannot bolt it on.