# Lightbox (Moonarc)

A thumbnail gallery whose images open full size: the image grows out of its own thumbnail on a same-document view transition and returns to it on close, and ← → move through the set. Built on Dialog morph: the dialog is native (Escape, the backdrop where closedby is supported, the close button, the focus trap), the growth is lib/morph, the caption and the count are a polite live region, and without JavaScript each thumbnail is a link to the full file.

- Import: `import Lightbox from '@moonarc/core/Lightbox'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/lightbox.json`
- Tier B · category ui · trigger click
- Readout: `<Lightbox columns={3}>`
- Browser support: newly (Chrome 111 · Firefox 144 · Safari 18); elsewhere: without view transitions, or under reduced motion, the dialog opens as a plain dialog; without closedby (Safari) a backdrop click does nothing, and Escape and the close button still close it
- Measured cost: 972 B raw JS · 501 B gzip · + Dialog morph + runtime + morph (with dependencies 3.9 kB raw; CSS 8.9 kB raw)
- Page: https://moonarc.dev/components/lightbox/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `id` | `string` | none | Dialog id; unique per page. |
| `items` | `{ src: string; alt: string; thumb?: string; caption?: string }[]` | none | The set. thumb defaults to src; alt is required: it names the thumbnail link and the full image. |
| `columns` | `number` | `3` | Thumbnail columns. |
| `ratio` | `string` | `'1'` | Thumbnail aspect ratio. |
| `label` | `string` | `'Gallery'` | Accessible name of the group and the dialog. |
| `prevLabel` | `string` | `'Previous image'` | Accessible name of the previous button. |
| `nextLabel` | `string` | `'Next image'` | Accessible name of the next button. |
| `closeLabel` | `string` | `'Close'` | Accessible name of the close button. |
| `polyfill` | `boolean` | `false` | Inline the invoker click handler (403 B raw). The thumbnails open through the script, so the Lightbox itself needs it for nothing; kept for parity. |

## Usage

```astro
<Lightbox
  id="gallery"
  columns={3}
  items={[
    { src: '/work/one.jpg', thumb: '/work/one-thumb.jpg', alt: 'Poster, 2025', caption: 'Poster, 2025' },
    { src: '/work/two.jpg', thumb: '/work/two-thumb.jpg', alt: 'Book cover', caption: 'Book, 2024' },
    { src: '/work/three.jpg', alt: 'Sign system' },
  ]}
/>
```

## Reduced motion

No morph: the dialog opens with the Dialog's 150 ms cross-fade and closes the same way.

## With ClientRouter

Bound through the shared runtime; the view-transition-name exists only for the duration of a morph, so a navigation never sees it.

## Craft

- The dialog is the picture: no panel padding, clipped corners, sized by the image up to 92 % of the viewport, so the morph is thumbnail → image, not thumbnail → box with an image in it.
- The full file is loaded when the image opens and never before; the thumbnails are the page's own <img> elements, lazy and decoded async. While it loads the image dims to 40 % rather than showing nothing.
- The return target follows the image on screen: after ← → the dialog morphs back into the thumbnail of the image you were looking at, and focus lands there too.
- The caption and the "2 / 3" count are one polite live region: a step with ← → or the buttons is read out, and the focus stays on the button that made it.
- One gallery is one component, set by `items` instead of a group id shared across instances, because the dialog, the counter and the keys belong to the set.
- The morph itself is Dialog morph: the same script, the same tokens, the same root cross-fade, measured once.

## Replaces

- Lightbox (PhotoSwipe / yet-another-react-lightbox)
- LayoutGrid (Aceternity)
- Image zoom (Medium Zoom)
- layoutId image (Framer Motion)

## Source

```astro
---
/**
 * Lightbox — thumbnails that open full size, the image growing out of its
 * own thumbnail and returning to it. Built on <Dialog morph>: the dialog is
 * native, the growth is a same-document view transition through lib/morph
 * (no view transitions, or reduced motion → it opens as a plain dialog).
 * The full image is loaded when it opens; the thumbnails are the images on
 * the page, and without JavaScript each is a link to the full file.
 * ← → and the buttons move through the set; the return target follows.
 */
import type { HTMLAttributes } from 'astro/types';
import Dialog from './Dialog.astro';

interface Item {
  src: string;
  alt: string;
  /** Thumbnail; default the full image. */
  thumb?: string;
  caption?: string;
}

interface Props extends HTMLAttributes<'div'> {
  /** Dialog id; unique per page. */
  id: string;
  items: Item[];
  /** Thumbnail columns. */
  columns?: number;
  /** Thumbnail aspect ratio (CSS). */
  ratio?: string;
  /** Accessible name of the gallery and the dialog. */
  label?: string;
  /** Accessible names of the previous, next and close buttons. */
  prevLabel?: string;
  nextLabel?: string;
  closeLabel?: string;
  /** Add the inline click handler for browsers without invoker commands. */
  polyfill?: boolean;
}

const { id, items, columns = 3, ratio = '1', label = 'Gallery', prevLabel = 'Previous image', nextLabel = 'Next image', closeLabel = 'Close', polyfill = false, class: className, style, ...rest } = Astro.props;
const inline = [`--ma-lightbox-cols:${columns}`, `--ma-lightbox-ratio:${ratio}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---

<div class:list={['ma-lightbox', className]} data-ma-lightbox style={inline} {...rest}>
  <div class="ma-lightbox__grid" role="group" aria-label={label}>
    {
      items.map((it, i) => (
        <a href={it.src} class="ma-lightbox__thumb" data-i={i} data-caption={it.caption}>
          <img src={it.thumb ?? it.src} alt={it.alt} loading="lazy" decoding="async" draggable="false" />
        </a>
      ))
    }
  </div>
  <Dialog id={id} morph polyfill={polyfill} closeLabel={closeLabel} class="ma-lightbox__dialog" aria-label={label}>
    <Fragment slot="trigger" />
    <figure class="ma-lightbox__figure">
      <img class="ma-lightbox__img" alt="" data-lb-img />
      <figcaption class="ma-lightbox__caption" aria-live="polite">
        <span data-lb-caption></span>
        <span class="ma-lightbox__count" data-lb-count></span>
      </figcaption>
    </figure>
    {items.length > 1 && (
      <>
        <button type="button" class="ma-lightbox__nav ma-lightbox__nav--prev" data-lb="-1" aria-label={prevLabel}>
          <svg aria-hidden="true" viewBox="0 0 16 16" width="16" height="16"><path d="M10 3L5 8l5 5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></svg>
        </button>
        <button type="button" class="ma-lightbox__nav ma-lightbox__nav--next" data-lb="1" aria-label={nextLabel}>
          <svg aria-hidden="true" viewBox="0 0 16 16" width="16" height="16"><path d="M6 3l5 5-5 5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></svg>
        </button>
      </>
    )}
  </Dialog>
</div>

<style is:global>
  @layer components {
    :where(.ma-lightbox__grid) {
      display: grid;
      grid-template-columns: repeat(var(--ma-lightbox-cols, 3), 1fr);
      gap: 0.5rem;
    }
    :where(.ma-lightbox__thumb) {
      display: block;
      aspect-ratio: var(--ma-lightbox-ratio, 1);
      border: 1px solid var(--ma-edge);
      border-radius: 0.75rem;
      overflow: clip;
      background: var(--ma-edge);
    }
    :where(.ma-lightbox__thumb img) {
      display: block;
      width: 100%;
      height: 100%;
      object-fit: cover;
      transition: scale var(--ma-duration) var(--ma-ease);
    }
    :where(.ma-lightbox__thumb:hover img) {
      scale: 1.04;
    }
    :where(.ma-lightbox__thumb:focus-visible) {
      outline: 2px solid currentColor;
      outline-offset: 3px;
    }
    /* the dialog is the image: no panel padding, clipped corners, sized by the picture */
    :where(.ma-lightbox) .ma-dialog {
      width: fit-content;
      max-width: min(92vw, 80rem);
      max-height: 92dvh;
      overflow: clip;
    }
    :where(.ma-lightbox) .ma-dialog__panel {
      padding: 0;
    }
    :where(.ma-lightbox) .ma-dialog__close button {
      background: var(--ma-panel);
      opacity: 0.9;
    }
    :where(.ma-lightbox__figure) {
      margin: 0;
    }
    :where(.ma-lightbox__img) {
      display: block;
      width: auto;
      height: auto;
      max-width: min(92vw, 80rem);
      max-height: calc(92dvh - 3rem);
      min-width: 16rem;
      min-height: 12rem;
      object-fit: contain;
      background: var(--ma-edge);
      transition: opacity var(--ma-duration-fast) var(--ma-ease-out);
    }
    :where(.ma-dialog[data-loading] .ma-lightbox__img) {
      opacity: 0.4;
    }
    :where(.ma-lightbox__caption) {
      display: flex;
      align-items: baseline;
      justify-content: space-between;
      gap: 1rem;
      min-height: 3rem;
      padding: 0.75rem 1rem;
      font-size: 0.8125rem;
      opacity: 0.85;
    }
    :where(.ma-lightbox__count) {
      font-family: ui-monospace, monospace;
      font-size: 0.6875rem;
      letter-spacing: 0.06em;
      opacity: 0.7;
      white-space: nowrap;
    }
    :where(.ma-lightbox__nav) {
      position: absolute;
      top: calc(50% - 1.5rem);
      display: grid;
      place-items: center;
      width: 2.5rem;
      height: 2.5rem;
      border: 1px solid var(--ma-edge);
      border-radius: 999px;
      background: var(--ma-panel);
      color: inherit;
      cursor: pointer;
      opacity: 0.85;
      transition: opacity var(--ma-duration-fast) var(--ma-ease-out), scale var(--ma-duration) var(--ma-ease);
    }
    :where(.ma-lightbox__nav:hover) {
      opacity: 1;
      scale: 1.06;
    }
    :where(.ma-lightbox__nav:focus-visible) {
      outline: 2px solid currentColor;
      outline-offset: 2px;
    }
    :where(.ma-lightbox__nav--prev) {
      left: 0.75rem;
    }
    :where(.ma-lightbox__nav--next) {
      right: 0.75rem;
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-lightbox__thumb img),
      :where(.ma-lightbox__nav) {
        transition: none;
        scale: 1;
      }
    }
  }
</style>

<script>
  import { onMount } from '../lib/runtime';
  import { morphOpen, target } from '../lib/morph';

  onMount<HTMLElement>('[data-ma-lightbox]', (root, { signal }) => {
    const dialog = root.querySelector('dialog') as HTMLDialogElement;
    const thumbs = [...root.querySelectorAll<HTMLAnchorElement>('.ma-lightbox__thumb')];
    const img = dialog.querySelector<HTMLImageElement>('[data-lb-img]')!;
    const cap = dialog.querySelector<HTMLElement>('[data-lb-caption]')!;
    const count = dialog.querySelector<HTMLElement>('[data-lb-count]')!;
    let i = 0;
    // the full image is loaded when shown; the return target of the morph follows the image on screen
    const show = (n: number) => {
      i = (n + thumbs.length) % thumbs.length;
      const t = thumbs[i]!;
      if (img.src !== t.href) {
        dialog.setAttribute('data-loading', '');
        img.src = t.href;
      }
      img.alt = t.querySelector('img')!.alt;
      cap.textContent = t.dataset.caption ?? '';
      count.textContent = `${i + 1} / ${thumbs.length}`;
      target(dialog, t);
    };
    img.addEventListener('load', () => dialog.removeAttribute('data-loading'), { signal });
    thumbs.forEach((t, n) =>
      t.addEventListener(
        'click',
        (e) => {
          e.preventDefault();
          show(n);
          morphOpen(dialog, t);
        },
        { signal },
      ),
    );
    // the two buttons and the two arrow keys are one step each way
    dialog.addEventListener('click', (e) => { const b = (e.target as Element).closest<HTMLElement>('[data-lb]'); b && show(i + +b.dataset.lb!); }, { signal });
    dialog.addEventListener('keydown', (e) => { const d = { ArrowRight: 1, ArrowLeft: -1 }[e.key]; d && show(i + d); }, { signal });
    // focus lands on the thumbnail of the image that was open, not the one that was clicked
    dialog.addEventListener('close', () => thumbs[i]!.focus(), { signal });
  });
</script>

```
