← Posts

Astro Justified Gallery Layout — a modern replacement for the Flickr classic


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 via ResizeObserver, uses Astro’s <Image /> for automatic optimization, optional chunked rendering via IntersectionObserver for 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 the lightbox prop.
  • galleryLoader() — Astro Content Loader that scans a directory and optionally reads EXIF/IPTC/XMP/GPS via exifr plus generates LQIP previews via sharp (~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 like Sony 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:

  1. Close the row with the current item.
  2. 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 formatESM onlyUMD (Node-style)
Dependencies0several
TypeScript typesbundledDefinitelyTyped, often stale
Astro componentbundled
Last releasev0.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.

← Posts