Astro Justified Gallery Layout — a modern replacement for the Flickr classic
My new npm package @altner/astro-justified-gallery-layout: a lean justified-layout gallery for Astro with EXIF/IPTC/GPS, LQIP previews, a virtualized variant for huge collections, and a built-in lightbox.
The photo section of this site has been running on Flickr’s justified-layout for years — a battle-tested piece of code, but technically out of step with how I write things now: UMD module, a handful of dependencies, no release since v4.1.0 (January 2021). For the next iteration of the photo section and a few other Astro projects, I wanted a layer that feels like an Astro component from the start — so I wrote one. The first public version is now out: @altner/astro-justified-gallery-layout.
The package is a from-scratch take in the tradition of Flickr’s original — substantially smaller, ESM-only, TypeScript types bundled, zero runtime dependencies in the core — and rounded out with the building blocks I actually needed for a real photo site.
What’s in the box
<JustifiedGallery />— the default component. Reflows on container resize viaResizeObserver, uses Astro’s<Image />for automatic optimization, optional chunked rendering viaIntersectionObserverfor long lists.<JustifiedGalleryVirtual />— DOM windowing for massive collections. The server renders an empty container, the client fetches metadata in chunks from a JSON endpoint and only keeps the visible rows in the DOM.<Lightbox />— native<dialog>modal with arrow-key navigation, swipe, ESC and backdrop close. Place once on the page, opt in per gallery via thelightboxprop.galleryLoader()— Astro Content Loader that scans a directory and optionally reads EXIF/IPTC/XMP/GPS viaexifrplus generates LQIP previews viasharp(~1 KB base64 placeholders for instant first paint).computeLayout()— the pure function powering it all, ~120 lines, framework-agnostic and usable outside Astro.readPhotoMeta()/formatCameraLine()— small helpers that turn EXIF into a human-readable line likeSony ILCE-7M3 · 35mm · f/2.8 · 1/250s · ISO 400.
Minimal example
---
import JustifiedGallery from '@altner/astro-justified-gallery-layout/JustifiedGallery.astro';
import Lightbox from '@altner/astro-justified-gallery-layout/Lightbox.astro';
import type { ImageMetadata } from 'astro';
const modules = import.meta.glob<{ default: ImageMetadata }>(
'../assets/photos/*.{jpg,jpeg,png,webp,avif}',
{ eager: true },
);
const images = Object.values(modules).map((m) => ({ src: m.default }));
---
<JustifiedGallery images={images} targetRowHeight={240} gap={6} chunkSize={30} lightbox />
<Lightbox />
That’s the whole setup. Drop photos into the directory, run astro dev, done — including lazy loading, LQIP placeholders and the lightbox.
The row-break trick
The interesting bit lives in computeLayout(). As items get added to a row, the function calculates the height needed for that row to fit the container width exactly. Once the height drops to or below the target, the algorithm compares two options:
- Close the row with the current item.
- Close the row without it — the item slides into the next row.
Whichever lands closer to the target height wins. The result: neither giant single-image rows nor squished thin rows. The last (incomplete) row stays left-aligned at target height — stretching three landscapes across 1500 px just looks ridiculous.
computeLayout() is deterministic, no DOM access, no side effects. Trivial to memoize, runs fine in a Web Worker.
Things deliberately left small
| this package (core) | flickr/justified-layout | |
|---|---|---|
| Size | ~120 LOC, ~1 kB min+gz | ~30 kB minified |
| Module format | ESM only | UMD (Node-style) |
| Dependencies | 0 | several |
| TypeScript types | bundled | DefinitelyTyped, often stale |
| Astro component | bundled | — |
| Last release | v0.6.0 (Apr 2026) | v4.1.0 (Jan 2021) |
exifr and sharp are optional peers — if you don’t need metadata or LQIPs, don’t install them. Missing peers degrade silently: no crash, just no feature.
What about this site?
The honest answer: eventually yes, right now no. The photo section here has grown its own logic over time — a hierarchical photo tree with index.md per set, pre-generated JSON sidecars with EXIF/IPTC/GPS, multi-locale titles per photo, locale-specific URL segments, plus statistics and worldmap views that all sit on top of the custom data shape. The package’s galleryLoader() expects a flat collection with a different EXIF schema and knows nothing about i18n — dropping it in as-is would break half the site.
The bigger break, though, is the workflow: the package reads tags, title, description and GPS straight from the image file (EXIF/IPTC/XMP) — not from separate JSON sidecars like the current setup. That means photos need pre-export prep in Lightroom (or another DAM): IPTC fields for Title, Description and Keywords have to be filled in there, and the export preset has to preserve IPTC and GPS on the JPG — otherwise the fields read back empty. Upside: no second data source, every photo carries its own metadata. Downside: existing JSONs would have to be written back into the images, or the loader would need a hybrid mode that reads both.
Realistically I’ll only swap in the package as the foundation during the next bigger overhaul of the photo stack — with cleanly maintained IPTC fields on the Lightroom side, mapped data structures, a layer for the set hierarchy, and a translation step for GPS and camera metadata. Until then the old setup keeps running here, and the new package lives its own life out on npm.
Install & status
Version 0.6.0, MIT licence, on npm:
npm install @altner/astro-justified-gallery-layout
Source in the monorepo on GitHub. Issues and feature requests welcome in the tracker.