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

src/components/ui/overlays/lightbox/Lightbox.astro
---
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>
src/components/ui/overlays/lightbox/LightboxCounter.astro
---
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
---
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
---
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.