Lightbox
Free · MITHeadless, accessible, and styled entirely through the attribute surface below — bring your own CSS system, or start from the reference theme.
Install
Without a flag you get no styling at all — plain HTML carrying the attribute surface below. --theme copies the same theme in two dialects;--bridge lumos re-points either one onto Lumos for Astro's tokens (it needs a theme to re-point, and Lumos itself, already in your project).
Unstyled — behavior only, you write the CSS
npx astrocraft-ui add overlays/lightboxPlain-CSS theme — no build step
npx astrocraft-ui add overlays/lightbox --theme cssTailwind theme — needs Tailwind v4
npx astrocraft-ui add overlays/lightbox --theme tailwindPlain-CSS theme, re-pointed onto Lumos tokens
npx astrocraft-ui add overlays/lightbox --theme css --bridge lumosTailwind theme, re-pointed onto Lumos tokens
npx astrocraft-ui add overlays/lightbox --theme tailwind --bridge lumosLive demo
Lightbox
Open it and use ←/→. The counter is a live region, so the arrow keys announce where you are — an<img> swap is otherwise completely silent. Close it and focus returns to the thumbnail you came from, which the platform does for free.
Attribute surface
With no class-merging machinery, these attributes are the whole extension point. State is a data-attribute or a native attribute; visibility is the hiddenattribute. Generated from source by pnpm slots.
| Component | Slots | Variants | Runtime state | Native state |
|---|---|---|---|---|
| `Lightbox.astro` | `lightbox` | — | — | — |
| `LightboxCounter.astro` | `lightbox-counter` | — | — | — |
| `LightboxImage.astro` | `lightbox-image` | — | — | — |
| `LightboxNav.astro` | `lightbox-nav` | — | — | — |
Source
What the command copies — 5 files, alias-free so the copy lands standing alone. This is the whole component; there is nothing else.
---
// src/components/ui/overlays/lightbox/Lightbox.astro — headless primitive (see ../../README.md).
// A full-screen image viewer, built on the same modal <dialog> shell as Dialog — open it with a
// DialogTrigger whose `for` matches this Lightbox's `id` (re-exported as LightboxTrigger), close it
// with a DialogClose, Escape, or a backdrop click. Compose LightboxImage / LightboxNav /
// LightboxCounter inside.
//
// Almost all of it is reuse: `_dialog` opens and closes it, `_overlay.css` fades it, and the
// platform returns focus to the thumbnail that opened it — a <dialog> restores focus to whatever was
// focused before `showModal()`, so the user lands back where they were in the grid rather than at
// the top of the page. `nextIndex` from `_listbox` does the wrapping arithmetic.
//
// What is left, and what this file is: arrow keys, the counter's announcement, and warming the
// neighbouring images so stepping through does not flash empty. The active image is the one without
// `hidden` — state lives in the DOM rather than in a variable here, so there is nothing to keep in
// sync and no way for the two to disagree.
import "../../_overlay.css";
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"dialog"> & { label?: string };
const { label = "Image viewer", class: className, ...rest } = Astro.props;
---
<dialog aria-label={label} class={className} data-slot="lightbox" {...rest}>
<slot />
</dialog>
<script>
import "../../_dialog";
import { nextIndex } from "../../_listbox";
import { onReadyOnce } from "../../_once";
function imagesOf(root: HTMLElement): HTMLElement[] {
return [...root.querySelectorAll<HTMLElement>('[data-slot="lightbox-image"]')];
}
/** Warm the images either side of `i` so stepping onto one paints from cache, not from the network. */
function preload(images: HTMLElement[], i: number) {
for (const step of [1, -1] as const) {
const neighbour = images[nextIndex(images.length, i, step)];
const src =
neighbour instanceof HTMLImageElement ? neighbour.currentSrc || neighbour.src : "";
if (src) new Image().src = src;
}
}
function show(root: HTMLElement, i: number) {
const images = imagesOf(root);
if (images.length === 0) return;
images.forEach((img, n) => (img.hidden = n !== i));
const counter = root.querySelector<HTMLElement>('[data-slot="lightbox-counter"]');
// The counter is `aria-live`, so writing it here IS the announcement that the image changed —
// an <img> swap is silent, and without this the viewer says nothing as you arrow through it.
if (counter) counter.textContent = `${i + 1} of ${images.length}`;
preload(images, i);
}
function step(root: HTMLElement, dir: 1 | -1) {
const images = imagesOf(root);
show(
root,
nextIndex(
images.length,
images.findIndex((img) => !img.hidden),
dir,
),
);
}
function wire(root: HTMLElement) {
root.addEventListener("click", (event) => {
if (!(event.target instanceof Element)) return;
const nav = event.target.closest<HTMLElement>('[data-slot="lightbox-nav"]');
if (nav) step(root, nav.dataset.direction === "prev" ? -1 : 1);
});
// Scoped to the dialog: while it is open it holds focus, so this cannot steal the arrow keys
// from a page behind it. Escape stays the platform's.
root.addEventListener("keydown", (event) => {
if (event.key === "ArrowRight") step(root, 1);
else if (event.key === "ArrowLeft") step(root, -1);
else return;
event.preventDefault();
});
const images = imagesOf(root);
show(
root,
Math.max(
0,
images.findIndex((img) => !img.hidden),
),
);
}
onReadyOnce('[data-slot="lightbox"]', wire);
</script>
---
// src/components/ui/overlays/lightbox/LightboxCounter.astro — Lightbox compound part (see ../../README.md).
// "3 of 8", written by the Lightbox each time the image changes.
//
// It is `aria-live` because it is doing two jobs at once: swapping one <img> for another is
// completely silent to a screen reader, so this element's update is the ONLY announcement that
// anything happened when you press the arrow key. Its text is filled in by the script, so it renders
// empty — which is correct, because before the script runs there is no position to report.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"p">;
const { class: className, ...rest } = Astro.props;
---
<p aria-live="polite" class={className} data-slot="lightbox-counter" {...rest}></p>
---
// src/components/ui/overlays/lightbox/LightboxImage.astro — Lightbox compound part (see ../../README.md).
// One image in the deck. Render them all; the Lightbox hides every one but the active image with the
// `hidden` attribute, which is the library's visibility contract and the single source of truth for
// which one is showing.
//
// `loading="lazy"` by default so a thirty-image gallery does not fetch thirty images on page load —
// the Lightbox warms the two neighbours of the active image itself, which is the only preloading
// that stepping through actually needs.
//
// `alt` is required, and it is not boilerplate here: a lightbox exists because the image IS the
// content, so an unlabelled one is a page with its subject missing.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"img"> & { src: string; alt: string };
const { alt, loading = "lazy", class: className, ...rest } = Astro.props;
---
<img alt={alt} loading={loading} class={className} data-slot="lightbox-image" {...rest} />
---
// src/components/ui/overlays/lightbox/LightboxNav.astro — Lightbox compound part (see ../../README.md).
// Previous / next. `direction` is both the behavior hook the Lightbox reads and the `data-direction`
// a theme flips the arrow off, so the two cannot drift apart.
//
// `label` gives it an accessible name because these are almost always icon-only, and "button" is
// what a screen reader says otherwise.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"button"> & { direction: "prev" | "next"; label?: string };
const { direction, label, class: className, ...rest } = Astro.props;
---
<button
type="button"
aria-label={label ?? (direction === "prev" ? "Previous image" : "Next image")}
data-slot="lightbox-nav"
data-direction={direction}
class={className}
{...rest}
>
<slot />
</button>
import {
DialogClose as LightboxClose,
DialogTrigger as LightboxTrigger,
} from "../../overlays/dialog";
import Lightbox from "./Lightbox.astro";
import LightboxCounter from "./LightboxCounter.astro";
import LightboxImage from "./LightboxImage.astro";
import LightboxNav from "./LightboxNav.astro";
export { Lightbox, LightboxClose, LightboxCounter, LightboxImage, LightboxNav, LightboxTrigger };
export default Lightbox;
What you get
The component source, copied into your project by npx astrocraft-ui add overlays/lightbox — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.