# Lightbox

**When to use:** A full-screen viewer for a set of images, opened from thumbnails: swipe, the arrows or the arrow keys move between them, and each one zooms and pans like a `PanZoom`. The thumbnail grid of a gallery is yours to lay out. For one zoomable image inline on the page, use `PanZoom`; for a slideshow that stays on the page, `Carousel`.

**Keywords:** photo, picture, fullscreen, enlarge, preview, album, product images, shared element transition, hero transition, halo

**Import:** `import { Lightbox } from '@ahrowe/ui'`  
**Types:** `import type { LightboxProps, LightboxImage, LightboxLabels } from '@ahrowe/ui'`

```tsx
import { useRef, useState } from 'react';
import { Lightbox, type LightboxImage } from '@ahrowe/ui';

const PHOTOS: LightboxImage[] = [
  { src: '/photos/harbour.jpg', thumbnail: '/photos/harbour-small.jpg', alt: 'Harbour at dawn', caption: 'Trieste, 6:12' },
  { src: '/photos/market.jpg', thumbnail: '/photos/market-small.jpg', alt: 'Fruit stall at the market' },
  { src: '/photos/tram.jpg', thumbnail: '/photos/tram-small.jpg', alt: 'Tram in the rain' },
];

function Gallery() {
  const [isOpen, setOpen] = useState(false);
  const [index, setIndex] = useState(0);
  const thumbnails = useRef<(HTMLImageElement | null)[]>([]);

  return (
    <>
      {PHOTOS.map((photo, i) => (
        <button key={photo.src} onClick={() => { setIndex(i); setOpen(true); }}>
          <img ref={(img) => { thumbnails.current[i] = img; }} src={photo.thumbnail} alt={photo.alt} width={160} />
        </button>
      ))}
      <Lightbox
        images={PHOTOS}
        isOpen={isOpen}
        onClose={() => setOpen(false)}
        currentIndex={index}
        onChange={setIndex}
        getThumbnail={(_, i) => thumbnails.current[i]}
      />
    </>
  );
}

// Uncontrolled: opens on defaultIndex every time it opens
<Lightbox images={PHOTOS} isOpen={isOpen} onClose={() => setOpen(false)} defaultIndex={2} />
```

Each `LightboxImage` is `{ src, alt, caption?, thumbnail?, width?, height? }`. `src` is the full-size image. Only the image on show and its two neighbours are mounted, so a large set loads as it is browsed rather than all at once.

**While it loads:** give each image the `thumbnail` your grid already shows. The browser has it, so the viewer opens on it at once, enlarged to the size the full image will take, and swaps in the full image once that has loaded. The image is never shown larger than its own pixel size, which only blurs it, so on a screen larger than the image the thumbnail has to know that size: give the image's `width` and `height`, as most image services and CMSs report them, and it takes it from the start. Without them it fills the screen until the image's first bytes are in, which carry its size, and then shrinks to it. A thumbnail in a different aspect ratio than the image (a square crop) jumps to the right shape when it does. Until the thumbnail or the image is there, a spinner stands in, from half a second on, so a cached image never flashes one; a skeleton would need the image's shape, which is unknown until it loads. The toolbar's zoom button stays disabled until the image itself is in, and a failed image has nothing to zoom. Each image is an [`Image`](Image.md). One that fails to load shows `labels.loadError` in its place, under which its caption stays.

**Moving between images:** swipe or drag at the fitted size, the previous and next buttons, or the left and right arrow keys from anywhere in the dialog. The set loops at both ends. A counter (`2 / 6`) sits top left; with a single image there is no counter and no arrows.

**Zooming:** every image is a `PanZoom` with `scrollToZoom` on: a pinch, a double tap or the mouse wheel zooms it, and a drag pans it. The magnifier in the toolbar zooms the image on show to 2x and, once zoomed, back to its fitted size; there are no zoom buttons on the image itself. While an image is zoomed in, a drag pans it instead of changing the image, and so do the arrow keys while the image has focus. An image you move away from while zoomed in zooms back out as it slides off. The arrow keys leave a field in a caption alone, and so does any arrow key pressed with a modifier, so Alt + left still goes back in the browser. Global `Carousel` defaults from `ConfigProvider` do not reach the viewer.

**Clearing the screen:** a tap on the image hides the toolbar, the arrows and the caption, and the next tap brings them back, the way a photo app clears the screen for the picture. It waits 300ms first, in case the tap is the first half of a double tap, which zooms instead. A mouse click leaves them alone, since on a desktop they are in reach and would only be lost, and any key brings them back, so the keyboard always has them to move to. They stay hidden while you swipe between images.

**Actions of your own:** `actions` renders buttons in the toolbar, before the close button, for the image on show. It gets that image and its index, so a button always acts on the picture being looked at, controlled or not. They are inside the dialog, so Tab reaches them, and the arrow keys still change the image while one has focus.

```tsx
<Lightbox
  images={photos}
  isOpen={isOpen}
  onClose={() => setOpen(false)}
  actions={(image, index) => (
    <>
      <ActionIcon icon={faDownload} aria-label='Download' href={image.src} download />
      <ActionIcon icon={faTrash} aria-label='Delete' onClick={() => setPhotos(photos.filter((_, i) => i !== index))} />
    </>
  )}
/>
```

A download link like the one above only downloads an image served from your own origin. For one on another origin (a CDN, an image service) browsers ignore `download` and open the image instead; serve it with `Content-Disposition: attachment`, or fetch it into a `blob:` URL first, which needs that server to allow CORS. Removing the image on show moves to the next one, and once `images` is empty the viewer renders nothing; `isOpen` is still yours to set back.

**Closing:** the close button, Escape and a swipe up or down call `onClose`. While open it traps focus, locks the page's scroll, and gives focus back to whatever opened it (the thumbnail) on close, the same way `Modal` does.

**Closing with the back button:** on a phone, people press back to leave a full-screen photo, and by default that leaves the page instead. The viewer does not touch the browser's history, which belongs to your router, so add an entry when it opens and close it when that entry is left:

```tsx
function openAt(i: number) {
  history.pushState({ lightbox: true }, '');
  setIndex(i);
  setOpen(true);
}

useEffect(() => {
  const onBack = () => setOpen(false);
  window.addEventListener('popstate', onBack);
  return () => window.removeEventListener('popstate', onBack);
}, []);

// Every other way of closing goes back too, so the entry does not stay behind.
<Lightbox images={PHOTOS} isOpen={isOpen} onClose={() => history.back()} currentIndex={index} onChange={setIndex} />
```

With a router, keep the open image in the URL instead (`?photo=2`): back then closes the viewer, and a link opens it on that image.

**Swipe to close:** at the fitted size, a vertical swipe moves the image with the finger and shrinks it, down to three quarters, while the backdrop fades and the toolbar, arrows and caption fade where they are. Released past a fifth of the screen's height, or flicked, it closes, and the image flies from where it was let go into its thumbnail, or on off the screen without one; otherwise it springs back. It works with a mouse too. A zoomed-in image keeps a vertical drag to pan, a pinch stays a pinch, and a horizontal swipe changes the image. It is built on [`useSwipeDismiss`](Hooks.md#useswipedismiss).

**Growing out of the thumbnail:** give `getThumbnail` the element in your grid that shows each image, usually its `<img>`, and the image grows out of the one that was tapped as the viewer opens: a square crop (`object-fit: cover`) opens out to the whole picture, and rounded corners straighten. Closing flies it back into the thumbnail of the image on show, which after a few swipes is not the one it opened from. While its image is on show or in flight the thumbnail is hidden, as if lifted out of the grid, so the picture never shows twice, behind a backdrop that is not opaque or one a swipe fades. It needs something to fly: the image's `thumbnail`, which is the usual case, or a full image already loaded. A thumbnail scrolled out of view, or none, falls back to the fade below.

**Animation:** without a thumbnail to grow out of, it fades in while the image scales up from 95%, and closing does the reverse over 0.25s before it unmounts. Focus and the page's scroll come back as soon as it starts closing, and it takes no input while it fades out. Under `prefers-reduced-motion: reduce` only the fades remain: nothing scales or flies, and a swipe that does not close jumps back rather than easing back.

**A glow behind the image:** `glow` puts a blurred, brightened copy of the image on show behind it, a little larger than it, so its colours spill out around it the way a screen's ambient light does. It lights up once the full image has loaded, crossfades to the next image's as you move between them, stays off while the image flies, and shrinks with it as a swipe moves it. It is off by default: a large blur costs frame rate on weaker phones, and the colours spill under the caption, whose text can lose contrast against the glow of a bright image. Change its strength through the `glow` slot, e.g. `styles={{ glow: { filter: 'blur(60px)' } }}`.

```tsx
<Lightbox images={PHOTOS} isOpen={isOpen} onClose={() => setOpen(false)} glow />
```

**Theming:** the backdrop is `--lightbox-background`, which falls back to the theme's opaque `--background`, so the viewer is light in a light theme and dark in a dark one. Set it to a fixed colour for a dark viewer everywhere, and set `color` on the `root` slot to match.

**Key props:**

| Prop | Type | Description |
|------|------|-------------|
| `images` | `LightboxImage[]` | The set, in order. Nothing renders while it is empty |
| `isOpen` | `boolean` | Whether the viewer is showing (default `false`) |
| `onClose` | `() => void` | Called on the close button and on Escape |
| `currentIndex` | `number` | Controlled index of the image on show |
| `defaultIndex` | `number` | Image to open on when uncontrolled, read each time it opens (default `0`) |
| `onChange` | `(index: number) => void` | Called whenever the image changes, from a swipe, the arrows or the arrow keys |
| `actions` | `(image: LightboxImage, index: number) => ReactNode` | Buttons of your own for the image on show, in the toolbar before the close button |
| `glow` | `boolean` | A blurred copy of the image behind it, so its colours spill out around it (default `false`) |
| `getThumbnail` | `(image: LightboxImage, index: number) => HTMLElement \| null \| undefined` | The element in your grid that shows the image. The image grows out of it on opening and flies back into the one on show on closing |

**Global defaults:** adopts `ConfigProvider`, e.g. `defaultProps={{ Lightbox: { labels: { close: 'Schließen' } } }}`. See [ConfigProvider.md](ConfigProvider.md). Global `PanZoom` defaults reach the images, `maxScale` for one.

**Slots:** `root` `toolbar` `counter` `actions` `zoomButton` `closeButton` `figure` `image` `glow` `caption`

**Labels:** `dialog` `close` `previous` `next` `zoomIn` `zoomOut` `loadError` `counter(position, count)` — the text this component renders of its own. Pass `labels` to override any of them, on the component or app-wide through `ConfigProvider`; they merge per key. Type: `LightboxLabels`.
