Skip to content

Components / UI

Lightbox

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.

Live demo

Study 1Study 2Study 3

click · grows from its thumbnail · ← →

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

972 B raw

501 B gzip.

Uses the Dialog morph script and the shared runtime (1.9 kB raw, once per site) and the shared morph helper (1.1 kB raw, once per site). With those included: 3.9 kB raw.

CSS 8.9 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · newly available

Chrome 111 · Firefox 144 · Safari 18; needs dialog, view-transitions. 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.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import Lightbox from '@moonarc/core/Lightbox';
---
<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' },
  ]}
/>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the shared runtime, the morph helper, Dialog and the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/lightbox.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy runtime.ts, morph.ts, Dialog.astro and internal/DialogMorph.astro.

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

Props

PropTypeDefaultDescription
idstringnoneDialog id; unique per page.
items{ src: string; alt: string; thumb?: string; caption?: string }[]noneThe set. thumb defaults to src; alt is required: it names the thumbnail link and the full image.
columnsnumber3Thumbnail columns.
ratiostring'1'Thumbnail aspect ratio.
labelstring'Gallery'Accessible name of the group and the dialog.
prevLabelstring'Previous image'Accessible name of the previous button.
nextLabelstring'Next image'Accessible name of the next button.
closeLabelstring'Close'Accessible name of the close button.
polyfillbooleanfalseInline the invoker click handler (403 B raw). The thumbnails open through the script, so the Lightbox itself needs it for nothing; kept for parity.

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.

Why it is built this way

Replaces: Lightbox (PhotoSwipe / yet-another-react-lightbox) · LayoutGrid (Aceternity) · Image zoom (Medium Zoom) · layoutId image (Framer Motion). See the migration table.

A scroll-snap strip with previous/next buttons and dot markers that the browser generates and keeps in sync.

Chrome 135 · not Firefox · not Safariclickpointer

dialog

Native, animated, zero script.

Escape, backdrop click and focus are the browser's. The scale and the blur are @starting-style and allow-discrete.

A native modal that scales in over a blurred backdrop and scales out again, closes on Escape, the close button or a backdrop click, and traps focus, all handled by the browser.

newly · Chrome 135 · Firefox 144 · Safari 26.2click
The scene as a line drawingThe scene in colour

drag · click · ← →

Compares a before and an after image with a line you drag between them.

newly · Chrome 124 · Firefox 121 · Safari 16.5pointerclick

click · morphs open

A poster with a breathing play button that opens the video in a dialog: a file with native controls, or an embed.

newly · Chrome 135 · Firefox 144 · Safari 26.2click

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown