This Astro gallery renders a grid of optimised thumbnails with <Image />, links each one to a larger version made with getImage(), and opens that version in a lightbox built on the native <dialog> element. Without JavaScript, each thumbnail is a normal link to the full image. With it, the link opens the lightbox instead, with Escape, arrow keys and a close button.
Key Takeaways
- Thumbnails use
<Image />withwidthsandsizes, so phones download small files.- Full-size images are generated at build time with
getImage(); nothing is processed in the browser.- Links work without JavaScript; the script upgrades them to a lightbox.
<dialog>handles focus and Escape, so the script stays short.
The component
---
// src/components/Gallery.astro
import { Image, getImage } from 'astro:assets';
import type { ImageMetadata } from 'astro';
interface Props { images: { src: ImageMetadata; alt: string }[] }
const { images } = Astro.props;
const items = await Promise.all(
images.map(async ({ src, alt }) => ({
src,
alt,
full: (await getImage({ src, width: 1800, format: 'webp' })).src,
})),
);
---
<ul class="gallery">
{items.map(({ src, alt, full }, i) => (
<li>
<a href={full} class="gallery-link" data-index={i}>
<Image src={src} alt={alt} widths={[320, 480, 640]} sizes="(min-width: 960px) 30vw, (min-width: 600px) 45vw, 90vw" />
</a>
</li>
))}
</ul>
<dialog class="lightbox" aria-label="Image viewer">
<img class="lightbox-img" alt="" />
<p class="lightbox-caption"></p>
<button type="button" class="lightbox-prev" aria-label="Previous image">‹</button>
<button type="button" class="lightbox-next" aria-label="Next image">›</button>
<form method="dialog"><button class="lightbox-close" aria-label="Close">×</button></form>
</dialog>
<style>
.gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); gap: 1rem; list-style: none; padding: 0; }
.gallery img { width: 100%; height: auto; border-radius: 0.5rem; display: block; }
.lightbox { max-width: 95vw; max-height: 95vh; padding: 0; border: 0; background: transparent; overflow: visible; }
.lightbox::backdrop { background: rgb(0 0 0 / 0.85); }
.lightbox-img { max-width: 95vw; max-height: 85vh; display: block; }
.lightbox-caption { color: #fff; text-align: center; margin: 0.75rem 0 0; }
.lightbox button { position: fixed; background: rgb(0 0 0 / 0.5); color: #fff; border: 0; font-size: 2rem; width: 3rem; height: 3rem; border-radius: 50%; cursor: pointer; }
.lightbox-prev { left: 1rem; top: 50%; }
.lightbox-next { right: 1rem; top: 50%; }
.lightbox-close { right: 1rem; top: 1rem; }
</style>
<script>
const links = [...document.querySelectorAll<HTMLAnchorElement>('.gallery-link')];
const dialog = document.querySelector<HTMLDialogElement>('.lightbox');
if (dialog && links.length) {
const img = dialog.querySelector<HTMLImageElement>('.lightbox-img')!;
const caption = dialog.querySelector<HTMLElement>('.lightbox-caption')!;
let current = 0;
const show = (i: number) => {
current = (i + links.length) % links.length;
const thumb = links[current].querySelector('img')!;
img.src = links[current].href;
img.alt = thumb.alt;
caption.textContent = thumb.alt;
};
links.forEach((link, i) =>
link.addEventListener('click', (e) => {
e.preventDefault();
show(i);
dialog.showModal();
}),
);
dialog.querySelector('.lightbox-prev')!.addEventListener('click', () => show(current - 1));
dialog.querySelector('.lightbox-next')!.addEventListener('click', () => show(current + 1));
dialog.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft') show(current - 1);
if (e.key === 'ArrowRight') show(current + 1);
});
dialog.addEventListener('close', () => links[current].focus());
}
</script>
Using it
---
import Gallery from '../components/Gallery.astro';
import one from '../assets/work/one.jpg';
import two from '../assets/work/two.jpg';
import three from '../assets/work/three.jpg';
---
<Gallery images={[
{ src: one, alt: 'Kitchen renovation, after' },
{ src: two, alt: 'Living room with oak floors' },
{ src: three, alt: 'Bathroom with green tiles' },
]} />
To load a whole folder, use import.meta.glob() with eager: true and map the modules to { src, alt }; the pattern is in Local images must be imported.
Notes
- One lightbox per page. The script assumes a single gallery. For several, scope the query selectors to each gallery’s wrapper.
- Build time. Each image produces three thumbnail widths and one full-size file. Large galleries lengthen builds; faster Astro builds in CI covers caching.
- Alt text doubles as the caption. Write it for both purposes, or add a separate caption field.
Thumbnail sizing is explained in responsive images in Astro, and the dialog pattern in the modal component. For portfolio sites with galleries already built, see portfolio themes. The astro:assets reference documents getImage().
FAQ
Does the gallery work without JavaScript?
Yes. Each thumbnail links to its full-size image, which opens as a normal page.
Can I use remote images?
Yes, if the domain is allowed in image.domains, and you pass width and height or inferSize.