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
// 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' },
}),
],
});
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" />
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.
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.
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:
Set orientation="vertical" and the same data stacks down the page on a left-hand 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.
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.JPGis recognised as camera noise and ignored), then"<album> photo N". -
Lazy loading without layout shift. Intrinsic
widthandheighton every<img>, plusloading="lazy"anddecoding="async". The first image is eager and high priority for LCP. -
Responsive images.
ImageGalleryandJustifiedGalleryemit asrcsetacross a set of widths, never upscaling past the source, with a matchingsizes. -
Structured data. Each gallery emits one
<script type="application/ld+json">with anImageGallerywhoseassociatedMediais a list ofImageObjectentries. Relative URLs become absolute whensiteis set inastro.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
- npm:
astro-gallery - Source and full docs: github.com/SlashGordon/astro-gallery





Top comments (0)