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, shown without its comments; the files you install keep them, with the reasoning behind every rule.
---
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"]')];
}
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"]');
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);
});
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>
---
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>
---
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} />
---
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.