DEV Community

Cover image for astro-gallery: four image gallery components for Astro
Christoph Dieck
Christoph Dieck

Posted on Originally published at slashgordon.link

astro-gallery: four image gallery components for Astro

Every time I write a post with photos in it, I need a gallery. The dog post needed one. The paving post needed three. I kept copying the same Astro component from post to post, tweaking it slightly each time, and the copies slowly drifted apart.

So I did the sensible thing and turned it into a package. It is called astro-gallery, it is on npm, and my blog now runs on it.

What astro-gallery is

astro-gallery is a set of four gallery components for Astro. You point a component at a folder in src/, drop your photos in, and it handles the rest: image optimisation through astro:assets, responsive srcset, a lightbox, EXIF reading at build time, and structured data. No client framework, no config file, one integration.

The four components:

Component What it does
JustifiedGallery Aspect-ratio-aware rows that fill the width edge to edge. Nothing gets cropped to a grid cell.
ImageGallery A uniform responsive grid. Click a thumbnail, get a lightbox with arrow-key navigation.
ImageTimeline Reads each photo's EXIF capture date, groups by day, lays the days out on a timeline. Horizontal rail or vertical spine.
MapGallery Reads GPS EXIF and drops a photo marker per location on a Leaflet map. Ships a consent gate so no tile request happens before the visitor agrees.

The photos in the examples below are real. A hiking trip in the Harz, a few days on Rügen, and some odds and ends from around Hamburg. All of them live in one folder, src/assets/images/demo/, and every example reads from that same folder.

Setup

Install the package and add the integration.

npm install astro-gallery
Enter fullscreen mode Exit fullscreen mode
// astro.config.mjs
import { defineConfig } from 'astro/config';
import gallery from 'astro-gallery';

export default defineConfig({
  integrations: [
    gallery({
      locale: 'en-GB',
      // pick a basemap for MapGallery. 'osm' is keyless.
      map: { basemap: 'osm' },
    }),
  ],
});
Enter fullscreen mode Exit fullscreen mode

The integration exposes its resolved config to the components through a virtual module. The components will not render without it, so this step is not optional.

Then import a component in any .astro or .mdx file and give it a folder:

---
import JustifiedGallery from 'astro-gallery/components/JustifiedGallery.astro';
---
<JustifiedGallery folderPath="demo" album="Trips 2025 and 2026" />
Enter fullscreen mode Exit fullscreen mode

JustifiedGallery

This is the one I reach for first. Rows are justified to the container width using each photo's aspect ratio, so a landscape shot takes more horizontal space than a portrait, and nothing is cropped to fit a box. It is the layout you know from Flickr, Unsplash and the Lightroom web galleries.

It is built for Core Web Vitals. Every image carries its intrinsic width and height so there is zero layout shift, offscreen rows use content-visibility to skip rendering work, and the first couple of images load eagerly with fetchpriority="high". The rest lazy load. There is a blur-up skeleton while each image decodes.

JustifiedGallery rendered with the demo trip photos. Aspect-ratio-justified rows roughly 240px tall, landscape and portrait shots sitting side by side, every row filling the full content width with no cropping.

The alt prop is a map of file name to alt text. You only fill in the ones that matter. For the rest, the component builds an alt string from the file name or from an embedded IPTC caption if the photo has one. More on that below.

ImageGallery

Same photos, uniform grid. Every cell is identical, so each image is cropped with object-fit: cover to fill it. Use this when your photos are roughly the same shape, or when the gallery is really just navigation and you want it tidy.

ImageGallery with the same photos in a uniform 4-column grid. Every cell the same size, images cropped square with object-fit cover.

It also takes an explicit images array if you would rather list imported images, /public paths or remote URLs by hand, with per-image alt and caption. That is the mode the older posts on my blog use.

ImageTimeline

Here is where the EXIF reading earns its keep. ImageTimeline reads DateTimeOriginal from each photo, groups the photos by calendar day, and lays the days out in order. If a day has GPS data it reverse-geocodes a location label for that group, once, at build time. Nothing hits the network in the browser.

Default orientation is a horizontal rail you scroll sideways:

ImageTimeline horizontal rail. Photos grouped into day columns along a scrollable timeline, each group with a date heading and a reverse-geocoded location label above its thumbnails.

Set orientation="vertical" and the same data stacks down the page on a left-hand spine:

ImageTimeline vertical variant. The same day groups stacked down the page against a left-hand spine, dates sitting on the spine.

The Rügen days cluster together in August 2025. The Harz days sit in June 2026. You can see the shape of both trips without a caption telling you.

MapGallery

MapGallery reads the GPS coordinates, builds one circular photo marker per location, and puts them on a Leaflet map. Photos without GPS are skipped. If none of the photos have GPS, the component renders nothing.

The important part is the consent gate. Map tiles come from a third-party CDN, and loading them sends the visitor's IP address to that provider. So MapGallery renders an overlay first and loads nothing from the network until the visitor clicks the button. The choice is remembered in localStorage.

MapGallery. Left: the consent overlay covering the map with a short privacy note and a load button. Right: after consent, a Leaflet map with circular photo markers at each GPS location from the demo set.

Underneath the map, in the HTML, there is a plain list of linked thumbnails with real alt text. Search engines and no-JS visitors get that list. The client script swaps in the interactive map once you accept the gate. Nobody loses the content.

You pick the look with a basemap preset: osm (keyless), carto-dark, carto-light, carto-voyager, stadia-dark or esri-satellite. The CARTO and Stadia presets need a free API key, which you pass as map.tileApiKey. On my blog the key lives in a Gitea secret and the config falls back to plain OpenStreetMap when it is not set.

What you get for free

The reason I bothered to package this properly, rather than keep copy-pasting, is the boring stuff. Every component renders crawlable, accessible markup on the server. The JavaScript only enhances it.

  • Semantic HTML. Galleries are a <ul> of <figure> elements. Each thumbnail is a real <a href> to the full-size image, so crawlers can follow it. The timeline is a <section> with an <ol> where each day heading is a <time datetime="...">.
  • Alt text that is not a file name. Resolution order: an explicit value you pass, then an embedded IPTC or XMP caption, then a humanised file name (IMG_4821.JPG is recognised as camera noise and ignored), then "<album> photo N".
  • Lazy loading without layout shift. Intrinsic width and height on every <img>, plus loading="lazy" and decoding="async". The first image is eager and high priority for LCP.
  • Responsive images. ImageGallery and JustifiedGallery emit a srcset across a set of widths, never upscaling past the source, with a matching sizes.
  • Structured data. Each gallery emits one <script type="application/ld+json"> with an ImageGallery whose associatedMedia is a list of ImageObject entries. Relative URLs become absolute when site is set in astro.config.

astro-gallery vs rolling your own

astro-gallery Hand-rolled component A JS lightbox library
Image optimisation astro:assets, built in You wire up getImage yourself Usually none, you pass URLs
EXIF date and GPS Built in, build time You add exifr and the plumbing Out of scope
Consent gate for map tiles Built in You build it Out of scope
Semantic HTML and JSON-LD Built in Whatever you remember to add Depends on the library
Client JS About 2 kB for the lightbox, Leaflet only on map pages Your call The whole library on every page
Config surface One integration, sensible defaults Full control, full maintenance Its own API

Rolling your own is fine. I did it for a year. The problem was never the first copy. It was the fifth copy, slightly different from the other four, with a bug I had already fixed somewhere else.

FAQ

Does it need a client framework like React?
No. The components are .astro files. The only client JavaScript is a small dependency-free lightbox, plus Leaflet on pages that use MapGallery.

Where do the images live?
Anywhere under src/. The default base folder is src/assets/images/, so folderPath="demo" reads from src/assets/images/demo/. ImageGallery also accepts imported images and remote URLs directly.

Does the reverse geocoding call an API on every build?
Only for coordinates it has not seen before. Results are cached to a JSON file that you commit, shared by ImageTimeline and MapGallery. After the first run, builds and CI never touch the network for geocoding.

Can I theme it?
Yes. Everything is namespaced under .asg-* and driven by CSS custom properties. Override the tokens in your own CSS. Light and dark are picked up from prefers-color-scheme.

Which Astro versions work?
Astro 4 and up. My blog runs Astro 7.

Links

Top comments (0)