Skip to main content
astrocraft-ui/ components · 101

Lightbox

Free · MIT

Headless, 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

Install command
npx astrocraft-ui add overlays/lightbox

Plain-CSS theme — no build step

Install command
npx astrocraft-ui add overlays/lightbox --theme css

Tailwind theme — needs Tailwind v4

Install command
npx astrocraft-ui add overlays/lightbox --theme tailwind

Plain-CSS theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add overlays/lightbox --theme css --bridge lumos

Tailwind theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add overlays/lightbox --theme tailwind --bridge lumos

Live 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.

Placeholder artwork 1 of 4

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.

ComponentSlotsVariantsRuntime stateNative 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
---
// 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
---
// 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
---
// 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
---
// 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>
src/components/ui/overlays/lightbox/index.ts
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.