# Zoom Lightbox

> A thumbnail that grows into a full-screen photo, pages sideways, and shrinks back into its tile when you drag it down, like Apple Photos.

An animated React component from KeyframeUI, installed as source with the shadcn CLI. Built with React, Tailwind CSS and Motion.

- Docs and live preview: https://keyframeui.com/components/zoom-lightbox
- Tags: Gallery, Gesture, Motion
- npm dependencies: motion, clsx, tailwind-merge

## Installation

Register the @keyframeui registry in `components.json` once per project:

```json
{
  "registries": {
    "@keyframeui": "https://keyframeui.com/r/{name}.json"
  }
}
```

Then add the component. It lands in `components/ui/zoom-lightbox.tsx`, and the CLI also adds `lib/utils.ts` (the `cn` helper) and installs the npm dependencies:

```bash
npx shadcn@latest add @keyframeui/zoom-lightbox
```

Or install by URL, without registering the namespace:

```bash
npx shadcn@latest add https://keyframeui.com/r/zoom-lightbox.json
```

## Usage

The main demo shows typical usage.

### Gallery

A photo grid. Tap a photo, swipe or use the arrow keys to page, then drag it down and it lands back in its tile.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/zoom-lightbox-demo
```

```tsx title="components/zoom-lightbox-demo.tsx"
import { ZoomLightbox, ZoomLightboxItem } from "@/components/ui/zoom-lightbox";

const photos = [
  { id: "1506905925346-21bda4d32df4", width: 4878, height: 3252, alt: "Snowy peaks rising out of a sea of clouds at sunrise", caption: "Above the cloud line, first light" },
  { id: "1433086966358-54859d0ed716", width: 4000, height: 6000, alt: "A tall waterfall under a stone footbridge in a green gorge", caption: "The falls, after a week of rain" },
  { id: "1493246507139-91e8fad9978e", width: 5472, height: 3648, alt: "Jagged mountains reflected in a turquoise lake at dusk", caption: "Still water, last light" },
  { id: "1500530855697-b586d89ba3ee", width: 3648, height: 5472, alt: "A two-lane road running through red desert cliffs", caption: "Somewhere past the last gas station" },
  { id: "1519681393784-d120267933ba", width: 4096, height: 2733, alt: "The Milky Way above snow-covered mountains", caption: "Twenty-second exposure, no moon" },
  { id: "1505144808419-1957a94ca61e", width: 3070, height: 5464, alt: "Aerial view of a wave breaking onto dark sand", caption: "From above the tide line" },
  { id: "1490750967868-88aa4486c946", width: 7952, height: 5304, alt: "Orange poppies against a blue sky", caption: "Poppies along the trail" },
  { id: "1501785888041-af3ef285b470", width: 5979, height: 3986, alt: "A rowing boat on a clear green alpine lake", caption: "Morning row across the lake" },
  { id: "1502082553048-f009c37129b9", width: 3741, height: 2494, alt: "A lone broad tree in a grassy field", caption: "The tree everyone stops for" },
];

const image = (id: string, width: number) =>
  `https://images.unsplash.com/photo-${id}?auto=format&fit=max&q=80&w=${width}`;

export function ZoomLightboxDemo() {
  return (
    <div className="w-full max-w-sm">
      <div className="mb-3 flex items-baseline justify-between px-0.5">
        <p className="text-sm font-medium text-zinc-900 dark:text-zinc-50">Recents</p>
        <p className="font-mono text-xs text-zinc-500 tabular-nums">{photos.length} photos</p>
      </div>
      <ZoomLightbox className="grid grid-cols-3 gap-1">
        {photos.map((photo) => (
          <ZoomLightboxItem
            key={photo.id}
            src={image(photo.id, 2000)}
            thumbnail={image(photo.id, 400)}
            width={photo.width}
            height={photo.height}
            alt={photo.alt}
            caption={photo.caption}
            className="aspect-square rounded-md"
          />
        ))}
      </ZoomLightbox>
      <p className="mt-4 text-center font-mono text-xs text-zinc-500">
        Tap a photo. Drag it down to put it back.
      </p>
    </div>
  );
}
```

## Examples

Finished sections built with Zoom Lightbox. Each one installs whole, with the component included.

### Product Images

A storefront grid with portrait tiles and rounder corners. Captions carry the name, colorway and price.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/zoom-lightbox-products-demo
```

```tsx title="components/zoom-lightbox-products-demo.tsx"
import { ZoomLightbox, ZoomLightboxItem } from "@/components/ui/zoom-lightbox";

const products = [
  { id: "1542291026-7eec264c27ff", width: 5472, height: 3648, name: "Ember Runner", color: "Crimson", price: "$140", tint: "bg-red-600" },
  { id: "1606107557195-0e29a4b5b4aa", width: 2400, height: 3000, name: "Volt Trainer", color: "Lime / Black", price: "$120", tint: "bg-lime-400" },
  { id: "1595950653106-6c9ebd614d3a", width: 4000, height: 6000, name: "Pastel Court", color: "Lilac / Mint", price: "$110", tint: "bg-violet-300" },
  { id: "1600185365483-26d7a4cc7519", width: 5000, height: 4000, name: "Coral Air", color: "Sail / Coral", price: "$150", tint: "bg-zinc-300" },
  { id: "1549298916-b41d501d3772", width: 5595, height: 3997, name: "Desert Low", color: "Wheat", price: "$130", tint: "bg-amber-500" },
  { id: "1608231387042-66d1773070a5", width: 9248, height: 6936, name: "Night Court", color: "White / Black", price: "$95", tint: "bg-zinc-900" },
];

const image = (id: string, width: number) =>
  `https://images.unsplash.com/photo-${id}?auto=format&fit=max&q=80&w=${width}`;

export function ZoomLightboxProductsDemo() {
  return (
    <section className="w-full max-w-3xl">
      <div className="mb-6 flex items-end justify-between gap-4">
        <div>
          <p className="font-mono text-xs uppercase tracking-widest text-zinc-500">Spring drop</p>
          <h3 className="mt-2 text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
            New arrivals
          </h3>
        </div>
        <p className="font-mono text-xs text-zinc-500 tabular-nums">{products.length} styles</p>
      </div>

      <ZoomLightbox className="grid grid-cols-2 gap-x-4 gap-y-6 sm:grid-cols-3">
        {products.map((product) => (
          <div key={product.id}>
            <ZoomLightboxItem
              src={image(product.id, 2000)}
              thumbnail={image(product.id, 600)}
              width={product.width}
              height={product.height}
              alt={`${product.name} sneaker in ${product.color}`}
              caption={`${product.name} · ${product.color} · ${product.price}`}
              className="aspect-[4/5] w-full rounded-2xl"
            />
            <div className="mt-3 flex items-start justify-between gap-3 px-0.5">
              <div className="min-w-0">
                <p className="truncate text-sm font-medium text-zinc-900 dark:text-zinc-50">
                  {product.name}
                </p>
                <p className="mt-0.5 flex items-center gap-1.5 text-xs text-zinc-500">
                  <span
                    aria-hidden
                    className={`size-2 rounded-full ring-1 ring-black/10 dark:ring-white/20 ${product.tint}`}
                  />
                  {product.color}
                </p>
              </div>
              <p className="text-sm text-zinc-900 tabular-nums dark:text-zinc-50">{product.price}</p>
            </div>
          </div>
        ))}
      </ZoomLightbox>
    </section>
  );
}
```

### Case Study

Shots spread through an article, in different grids. The viewer pages through them in reading order and scrolls each one into view so it has a tile to land in.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/zoom-lightbox-case-study-demo
```

```tsx title="components/zoom-lightbox-case-study-demo.tsx"
import { ZoomLightbox, ZoomLightboxItem } from "@/components/ui/zoom-lightbox";

const shots = {
  exterior: { id: "1600585154340-be6161a56a0c", width: 4500, height: 3000, alt: "A low timber and glass house lit up at dusk", caption: "The street side at dusk. The old garage is now the entrance." },
  chair: { id: "1586023492125-27b2c045efd7", width: 3400, height: 3000, alt: "A yellow armchair beside a floor lamp against a white wall", caption: "Reading corner in the old hallway." },
  sofa: { id: "1555041469-a586c61ea9bc", width: 5760, height: 3840, alt: "A green velvet sofa on a pale wooden floor", caption: "The snug, with the original floorboards kept." },
  living: { id: "1618221195710-dd6b41faaea6", width: 6144, height: 3240, alt: "A long open living room with a sofa, coffee table and wide windows", caption: "One room from the front door to the garden." },
};

const image = (id: string, width: number) =>
  `https://images.unsplash.com/photo-${id}?auto=format&fit=max&q=80&w=${width}`;

function Shot({
  shot,
  aspect,
  className,
}: {
  shot: (typeof shots)[keyof typeof shots];
  aspect: string;
  className?: string;
}) {
  return (
    <figure className={className}>
      <ZoomLightboxItem
        src={image(shot.id, 2400)}
        thumbnail={image(shot.id, 1200)}
        width={shot.width}
        height={shot.height}
        alt={shot.alt}
        caption={shot.caption}
        className={`w-full rounded-xl ${aspect}`}
      />
      <figcaption className="mt-2 text-xs text-zinc-500">{shot.caption}</figcaption>
    </figure>
  );
}

// Items can sit anywhere inside <ZoomLightbox>, between paragraphs and in
// different grids. The viewer pages through all of them in reading order.
export function ZoomLightboxCaseStudyDemo() {
  return (
    <div className="w-full overflow-hidden rounded-xl border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-950">
      <div className="h-[var(--demo-height,36rem)] overflow-y-auto overscroll-contain">
        <ZoomLightbox className="mx-auto max-w-2xl px-6 py-10 sm:px-10">
          <p className="font-mono text-xs uppercase tracking-widest text-zinc-500">
            Case study · Residential
          </p>
          <h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance text-zinc-900 dark:text-zinc-50">
            The Hollow House
          </h2>
          <p className="mt-3 text-sm leading-relaxed text-pretty text-zinc-600 dark:text-zinc-400">
            A 1960s bungalow opened up into one long room that follows the light from the street to
            the garden.
          </p>
          <dl className="mt-6 grid grid-cols-3 gap-4 border-y border-zinc-200 py-4 text-xs dark:border-zinc-800">
            {[
              ["Client", "Private"],
              ["Year", "2026"],
              ["Scope", "Interiors"],
            ].map(([term, value]) => (
              <div key={term}>
                <dt className="font-mono uppercase tracking-widest text-zinc-500">{term}</dt>
                <dd className="mt-1 text-zinc-900 dark:text-zinc-100">{value}</dd>
              </div>
            ))}
          </dl>

          <Shot shot={shots.exterior} aspect="aspect-[3/2]" className="mt-8" />

          <h3 className="mt-14 text-base font-medium text-zinc-900 dark:text-zinc-50">The brief</h3>
          <p className="mt-2 text-sm leading-relaxed text-pretty text-zinc-600 dark:text-zinc-400">
            Four small rooms, a corridor and a garage that hadn&apos;t held a car in decades. The
            owners wanted somewhere to read, somewhere to sit together, and to see the garden from
            the front door.
          </p>

          <div className="mt-8 grid grid-cols-2 gap-3">
            <Shot shot={shots.chair} aspect="aspect-[4/5]" />
            <Shot shot={shots.sofa} aspect="aspect-[4/5]" />
          </div>

          <h3 className="mt-14 text-base font-medium text-zinc-900 dark:text-zinc-50">One room</h3>
          <p className="mt-2 text-sm leading-relaxed text-pretty text-zinc-600 dark:text-zinc-400">
            Taking out two walls turned the corridor into the house&apos;s spine. Everything else
            hangs off it: the snug, the reading corner, and the long room that opens onto the
            garden.
          </p>

          <Shot shot={shots.living} aspect="aspect-[2/1]" className="mt-8" />
        </ZoomLightbox>
      </div>
    </div>
  );
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `src` (required) | `string` |  | `ZoomLightboxItem`: the full-size image shown in the viewer. |
| `alt` (required) | `string` |  | `ZoomLightboxItem`: describes the image, for both the tile and the viewer. |
| `thumbnail` | `string` |  | `ZoomLightboxItem`: a smaller image for the tile, with the same aspect ratio. Defaults to `src`. The full image is preloaded on hover or focus. |
| `caption` | `React.ReactNode` |  | `ZoomLightboxItem`: shown under the photo in the viewer. |
| `width / height` | `number` |  | `ZoomLightboxItem`: intrinsic size of `src`. Lets the viewer size the photo before it loads. Without them the tile image's own ratio is used. |
| `className` | `string` |  | `ZoomLightboxItem`: classes for the tile. Give it a size or aspect ratio; its corner radius carries into the zoom. `ZoomLightbox` also takes `className` and other `div` props for its wrapper. |
| `imageClassName` | `string` |  | `ZoomLightboxItem`: classes for the tile's `<img>`. |
| `overlayClassName` | `string` | `"bg-background"` | `ZoomLightbox`: classes for the backdrop behind an open photo. Items can sit anywhere inside `ZoomLightbox`; the viewer pages through them in document order. |

## Source

```tsx title="components/ui/zoom-lightbox.tsx"
"use client";

import * as React from "react";
import { animate, motion, useMotionValue, useTransform, type MotionValue } from "motion/react";

import { cn } from "@/lib/utils";

/* ─────────────────────────────────────────────────────────
 * ZOOM LIGHTBOX STORYBOARD
 *
 *  open    tap a tile: the photo lifts out of the grid and grows
 *          to fit the screen, uncropping from the tile's shape
 *          and squaring its corners while the page fades away
 *  page    drag sideways, press ← →, or swipe a trackpad: one
 *          photo at a time, rubber-banding past either end.
 *          The page scrolls behind so every photo has a tile
 *          to return to
 *  lift    drag down: the photo comes loose under the finger,
 *          shrinking and rounding as the page shows through
 *  home    let go moving down and it flies back into its tile
 *          at the finger's speed; let go moving up and it
 *          springs back to full screen
 *  catch   grab it on the way home to pull it back out
 *  dim     tap the photo, the moon button or H: the controls
 *          hide on black, and a short note says how to get
 *          them back, in the words of whatever dimmed it
 *  close   tap outside the photo, Esc or ×: send it home
 * ───────────────────────────────────────────────────────── */

const LIGHTBOX = {
  open: { type: "spring", visualDuration: 0.42, bounce: 0.12 },
  home: { type: "spring", visualDuration: 0.4, bounce: 0.16 },
  settle: { type: "spring", visualDuration: 0.36, bounce: 0.2 }, // a cancelled drag springs back
  page: { type: "spring", visualDuration: 0.38, bounce: 0 },
  fadeIn: { duration: 0.3, ease: "easeOut" },
  fadeOut: { duration: 0.18, ease: "easeOut" },
  gap: 24, // px between photos while paging
  lock: 8, // px a press travels before a drag commits to an axis
  minScale: 0.5, // a dragged photo shrinks no smaller than this
  shrink: 1.1, // drag distance, in viewport heights, to reach minScale
  reveal: 0.45, // drag distance, in viewport heights, to fade the page in fully
  dismiss: 0.12, // projected drag, in viewport heights, that sends a photo home
  flick: 400, // px/s; a faster release counts as a flick
  lift: 16, // corner radius a dragged photo picks up, px
} as const;

// How to bring the controls back, worded for whatever hid them.
const HINTS = {
  touch: "Tap the photo to show controls",
  mouse: "Click the photo or press H to show controls",
  key: "Press H to show controls",
};
type HintSource = keyof typeof HINTS;

// Room around the photo for the controls. Below `narrow` it runs edge to edge.
const FRAME = { narrow: 640, inset: { x: 0, y: 64 }, wideInset: { x: 88, y: 72 } };

type Viewport = { width: number; height: number };
type Box = { x: number; y: number; width: number; height: number };
/**
 * Where the photo sits relative to its resting box: an offset and scale, the
 * crop from each edge in the photo's own px, and the corner radius in screen px.
 */
type Pose = {
  x: number;
  y: number;
  scale: number;
  top: number;
  right: number;
  bottom: number;
  left: number;
  radius: number;
};

type ItemData = {
  src: string;
  thumbnail?: string;
  alt: string;
  caption?: React.ReactNode;
  width?: number;
  height?: number;
};
type Entry = ItemData & { button: HTMLButtonElement | null; image: HTMLImageElement | null };
type Slide = ItemData & { id: string; aspect: number };
type Session = {
  key: number;
  slides: Slide[];
  index: number;
  origin: Pose | null;
  viewport: Viewport;
  reduceMotion: boolean;
};

const LightboxContext = React.createContext<{
  entries: Map<string, Entry>;
  open: (id: string) => void;
} | null>(null);

export interface ZoomLightboxProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Classes for the backdrop behind an open photo. Defaults to `bg-background`. */
  overlayClassName?: string;
}

/**
 * Groups `ZoomLightboxItem`s. Items can sit anywhere inside it, in any layout;
 * the viewer pages through them in document order.
 */
export function ZoomLightbox({ overlayClassName, className, children, ...props }: ZoomLightboxProps) {
  const [entries] = React.useState(() => new Map<string, Entry>());
  const [session, setSession] = React.useState<Session | null>(null);
  const sessions = React.useRef(0);

  const open = React.useCallback(
    (id: string) => {
      const ordered = [...entries]
        .flatMap(([key, entry]) => (entry.button?.isConnected ? [{ key, entry, button: entry.button }] : []))
        .sort((a, b) =>
          a.button.compareDocumentPosition(b.button) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1,
        );
      const index = ordered.findIndex(({ key }) => key === id);
      if (index === -1) return;

      const slides = ordered.map(({ key, entry }) => ({
        id: key,
        src: entry.src,
        thumbnail: entry.thumbnail,
        alt: entry.alt,
        caption: entry.caption,
        aspect: aspectOf(entry),
      }));
      const viewport = readViewport();
      const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
      const origin = reduceMotion
        ? null
        : tilePose(entries.get(id), fit(slides[index].aspect, viewport), viewport);
      for (const slide of slides.slice(Math.max(index - 1, 0), index + 2)) preload(slide.src);

      sessions.current += 1;
      const next = { key: sessions.current, slides, index, origin, viewport, reduceMotion };
      setSession((current) => current ?? next);
    },
    [entries],
  );

  const context = React.useMemo(() => ({ entries, open }), [entries, open]);

  return (
    <LightboxContext.Provider value={context}>
      <div className={className} {...props}>
        {children}
      </div>
      {session ? (
        <Viewer
          key={session.key}
          session={session}
          entries={entries}
          overlayClassName={overlayClassName}
          onClosed={() => setSession(null)}
        />
      ) : null}
    </LightboxContext.Provider>
  );
}

export interface ZoomLightboxItemProps
  extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
  /** The full-size image shown in the viewer. */
  src: string;
  /** A smaller image for the tile. Defaults to `src`. Should have the same aspect ratio. */
  thumbnail?: string;
  /** Describes the image. Used for the tile and the viewer. */
  alt: string;
  /** Shown under the photo in the viewer. */
  caption?: React.ReactNode;
  /** Intrinsic width of `src`. With `height`, sizes the viewer before the image loads. */
  width?: number;
  /** Intrinsic height of `src`. */
  height?: number;
  /** Classes for the tile's `<img>`. */
  imageClassName?: string;
}

/** A tile that opens its photo in the viewer. Size and round it with `className`. */
export function ZoomLightboxItem({
  src,
  thumbnail,
  alt,
  caption,
  width,
  height,
  className,
  imageClassName,
  onClick,
  onPointerEnter,
  onFocus,
  ...props
}: ZoomLightboxItemProps) {
  const { entries, open } = useLightbox();
  const id = React.useId();
  const buttonRef = React.useRef<HTMLButtonElement>(null);
  const imageRef = React.useRef<HTMLImageElement>(null);

  React.useLayoutEffect(() => {
    entries.set(id, {
      src,
      thumbnail,
      alt,
      caption,
      width,
      height,
      button: buttonRef.current,
      image: imageRef.current,
    });
  });
  React.useLayoutEffect(() => () => void entries.delete(id), [entries, id]);

  // Warm up the full-size image while the pointer is on its way.
  const warm = () => {
    if (thumbnail && thumbnail !== src) preload(src);
  };

  return (
    <button
      ref={buttonRef}
      type="button"
      aria-haspopup="dialog"
      className={cn(
        "group relative block cursor-zoom-in overflow-hidden bg-muted outline-none select-none [-webkit-tap-highlight-color:transparent]",
        "transition-transform duration-200 ease-out active:scale-[0.98] motion-reduce:transition-none motion-reduce:active:scale-100",
        "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground",
        className,
      )}
      onClick={(event) => {
        onClick?.(event);
        if (!event.defaultPrevented) open(id);
      }}
      onPointerEnter={(event) => {
        onPointerEnter?.(event);
        warm();
      }}
      onFocus={(event) => {
        onFocus?.(event);
        warm();
      }}
      {...props}
    >
      <img
        ref={imageRef}
        src={thumbnail ?? src}
        alt={alt}
        loading="lazy"
        decoding="async"
        draggable={false}
        className={cn(
          "size-full object-cover transition-transform duration-500 ease-out motion-safe:group-hover:scale-[1.03]",
          imageClassName,
        )}
      />
    </button>
  );
}

type Drag = {
  pointer: number;
  mode: "pending" | "page" | "lift";
  onPhoto: boolean;
  startX: number;
  startY: number;
  samples: { x: number; y: number; t: number }[];
  // page
  from: number;
  trackFrom: number;
  // lift
  centerX: number;
  centerY: number;
  grabX: number;
  grabY: number;
  scaleFrom: number;
  backdropFrom: number;
};

function Viewer({
  session,
  entries,
  overlayClassName,
  onClosed,
}: {
  session: Session;
  entries: Map<string, Entry>;
  overlayClassName?: string;
  onClosed: () => void;
}) {
  const { slides, origin, reduceMotion } = session;
  const count = slides.length;

  const [viewport, setViewport] = React.useState(session.viewport);
  const [index, setIndex] = React.useState(session.index);
  // The last index the track came to rest on. Photos between it and `index`
  // stay mounted, so a fast run of ← → never flies past an empty slot.
  const [anchor, setAnchor] = React.useState(session.index);
  const [immersive, setImmersive] = React.useState(false);
  const [aspects, setAspects] = React.useState<Record<string, number>>({});
  const [hintText, setHintText] = React.useState("");
  const tooltipId = React.useId();

  const dialogRef = React.useRef<HTMLDialogElement>(null);
  const stageRef = React.useRef<HTMLDivElement>(null);
  const live = React.useRef({
    index: session.index,
    closing: false,
    done: false,
    generation: 0,
    hidden: null as HTMLElement | null,
    drag: null as Drag | null,
    wheel: { sum: 0, locked: false, timer: 0 },
  });

  // The open photo. It starts out posed exactly over its tile.
  const x = useMotionValue(origin?.x ?? 0);
  const y = useMotionValue(origin?.y ?? 0);
  const scale = useMotionValue(origin?.scale ?? (reduceMotion ? 1 : 0.94));
  const top = useMotionValue(origin?.top ?? 0);
  const right = useMotionValue(origin?.right ?? 0);
  const bottom = useMotionValue(origin?.bottom ?? 0);
  const left = useMotionValue(origin?.left ?? 0);
  const radius = useMotionValue(origin?.radius ?? 0);
  const opacity = useMotionValue(origin ? 1 : 0);
  const clipPath = useTransform(() => {
    const crop = [top, right, bottom, left].map((v) => `${Math.max(v.get(), 0)}px`).join(" ");
    return `inset(${crop} round ${radius.get() / Math.max(scale.get(), 0.01)}px)`;
  });
  const pose = React.useMemo(
    () => ({ x, y, scale, top, right, bottom, left, radius, opacity }),
    [x, y, scale, top, right, bottom, left, radius, opacity],
  );

  const pitch = viewport.width + LIGHTBOX.gap;
  const track = useMotionValue(-session.index * pitch);
  const backdrop = useMotionValue(0);
  const shade = useMotionValue(0); // black behind the photo while the controls are hidden
  const shadeOpacity = useTransform(() => shade.get() * backdrop.get());
  const chrome = useMotionValue(0);
  const hint = useMotionValue(0);

  const boxOf = (i: number) => fit(aspects[slides[i].id] ?? slides[i].aspect, viewport);

  // Open: show the dialog, hide the tile the photo came from, and grow it in.
  React.useLayoutEffect(() => {
    const dialog = dialogRef.current;
    if (!dialog) return;
    if (!dialog.open) dialog.showModal();
    stageRef.current?.focus({ preventScroll: true });
    hideTile(live.current, entries, slides[session.index].id, reduceMotion);

    if (origin) {
      for (const value of [pose.x, pose.y, pose.top, pose.right, pose.bottom, pose.left, pose.radius]) {
        animate(value, 0, LIGHTBOX.open);
      }
      animate(pose.scale, 1, { ...LIGHTBOX.open, restDelta: 0.001 });
    } else {
      animate(pose.opacity, 1, LIGHTBOX.fadeIn);
      animate(pose.scale, 1, reduceMotion ? { duration: 0 } : LIGHTBOX.open);
    }
    animate(backdrop, 1, LIGHTBOX.fadeIn);
    animate(chrome, 1, { ...LIGHTBOX.fadeIn, delay: 0.12 });

    const state = live.current;
    return () => hideTile(state, entries, null, reduceMotion);
  }, [session, slides, origin, reduceMotion, entries, pose, backdrop, chrome]);

  // Follow the window size; keep the current photo centered.
  React.useLayoutEffect(() => {
    const dialog = dialogRef.current;
    if (!dialog) return;
    const observer = new ResizeObserver(() => {
      const width = dialog.clientWidth;
      const height = dialog.clientHeight;
      if (!width || !height) return;
      setViewport((prev) => (prev.width === width && prev.height === height ? prev : { width, height }));
    });
    observer.observe(dialog);
    return () => observer.disconnect();
  }, []);

  React.useLayoutEffect(() => {
    track.jump(-live.current.index * pitch);
  }, [pitch, track]);

  // Landed: show the tile, close the dialog and hand focus to the tile, all in
  // one task so no frame shows both or neither. Also runs if the browser
  // closes the dialog on its own (a second Esc).
  const finish = () => {
    const state = live.current;
    if (state.done) return;
    state.done = true;
    hideTile(state, entries, null, reduceMotion);
    dialogRef.current?.close();
    entries.get(slides[state.index].id)?.button?.focus({ preventScroll: true });
    onClosed();
  };

  const close = (velocity = { x: 0, y: 0 }) => {
    const state = live.current;
    if (state.done) return;
    state.closing = true;
    state.drag = null;
    setImmersive(false);
    animate(hint, 0, LIGHTBOX.fadeOut);
    const generation = ++state.generation;
    const target = reduceMotion
      ? null
      : tilePose(entries.get(slides[state.index].id), boxOf(state.index), viewport);

    animate(chrome, 0, LIGHTBOX.fadeOut);
    animate(shade, 0, LIGHTBOX.fadeOut);
    const runs = [
      animate(backdrop, 0, { duration: 0.32, ease: "easeOut" }),
      animate(track, -state.index * pitch, LIGHTBOX.page),
    ];
    if (target) {
      runs.push(
        animate(x, target.x, { ...LIGHTBOX.home, velocity: velocity.x }),
        animate(y, target.y, { ...LIGHTBOX.home, velocity: velocity.y }),
        animate(scale, target.scale, { ...LIGHTBOX.home, restDelta: 0.001 }),
        animate(top, target.top, LIGHTBOX.home),
        animate(right, target.right, LIGHTBOX.home),
        animate(bottom, target.bottom, LIGHTBOX.home),
        animate(left, target.left, LIGHTBOX.home),
        animate(radius, target.radius, LIGHTBOX.home),
        animate(opacity, 1, LIGHTBOX.fadeOut),
      );
    } else {
      // No tile on screen to return to: fade out where it is.
      runs.push(animate(opacity, 0, LIGHTBOX.fadeOut));
      if (!reduceMotion) runs.push(animate(scale, scale.get() * 0.94, LIGHTBOX.fadeOut));
    }
    Promise.all(runs).then(() => {
      if (live.current.generation === generation) finish();
    });
  };

  const settle = (velocity = { x: 0, y: 0 }) => {
    const spring = LIGHTBOX.settle;
    animate(x, 0, { ...spring, velocity: velocity.x });
    animate(y, 0, { ...spring, velocity: velocity.y });
    animate(scale, 1, { ...spring, restDelta: 0.001 });
    for (const value of [top, right, bottom, left, radius]) animate(value, 0, spring);
    animate(opacity, 1, LIGHTBOX.fadeIn);
    animate(backdrop, 1, LIGHTBOX.fadeIn);
    if (!immersive) animate(chrome, 1, LIGHTBOX.fadeIn);
  };

  const go = (next: number, velocity = 0, fromGesture = false) => {
    const state = live.current;
    if (state.closing) return;
    const target = clamp(next, 0, count - 1);
    if (target !== state.index) {
      state.index = target;
      setIndex(target);
      hideTile(state, entries, slides[target].id, reduceMotion);
      // Bring its tile into view behind the viewer, so it has somewhere to land.
      entries
        .get(slides[target].id)
        ?.button?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "instant" });
      for (const slide of slides.slice(Math.max(target - 1, 0), target + 2)) preload(slide.src);
    }
    const settled = () => {
      if (live.current.index === target) setAnchor(target);
    };
    if (reduceMotion && !fromGesture) {
      track.jump(-target * pitch);
      settled();
      return;
    }
    animate(
      track,
      -target * pitch,
      reduceMotion ? LIGHTBOX.fadeOut : { ...LIGHTBOX.page, velocity },
    ).then(settled);
  };

  const toggleChrome = (source: HintSource) => {
    const next = !immersive;
    setImmersive(next);
    animate(shade, next ? 1 : 0, { duration: 0.35, ease: "easeInOut" });
    animate(chrome, next ? 0 : 1, next ? LIGHTBOX.fadeOut : LIGHTBOX.fadeIn);
    if (next) {
      // Keys keep working once the controls (and maybe the focused one) are gone.
      stageRef.current?.focus({ preventScroll: true });
      setHintText(HINTS[source]);
      // Waits for the screen to darken, holds about a second and a half, fades.
      animate(hint, [0, 0, 1, 1, 0], { duration: 2.4, times: [0, 0.12, 0.22, 0.8, 1] });
    } else {
      animate(hint, 0, LIGHTBOX.fadeOut);
    }
  };

  // ── Gestures ────────────────────────────────────────────

  const beginPage = (drag: Drag, event: React.PointerEvent) => {
    track.stop();
    drag.mode = "page";
    drag.from = live.current.index;
    drag.trackFrom = track.get();
    drag.startX = event.clientX;
  };

  const beginLift = (drag: Drag, event: React.PointerEvent) => {
    const i = live.current.index;
    const box = boxOf(i);
    for (const value of [x, y, scale, radius]) value.stop();
    drag.mode = "lift";
    drag.centerX = box.x + box.width / 2 + i * pitch + track.get();
    drag.centerY = box.y + box.height / 2;
    drag.scaleFrom = scale.get();
    // The point under the finger, in the photo's own px from its center, so
    // it stays under the finger however much the photo shrinks.
    drag.grabX = (event.clientX - drag.centerX - x.get()) / drag.scaleFrom;
    drag.grabY = (event.clientY - drag.centerY - y.get()) / drag.scaleFrom;
    drag.startX = event.clientX;
    drag.startY = event.clientY;
    backdrop.stop();
    drag.backdropFrom = backdrop.get();
    for (const value of [top, right, bottom, left]) animate(value, 0, LIGHTBOX.settle);
    animate(opacity, 1, LIGHTBOX.fadeIn);
    animate(chrome, 0, LIGHTBOX.fadeOut);
  };

  const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
    const state = live.current;
    if (state.done || state.drag || (event.pointerType === "mouse" && event.button !== 0)) return;

    const onPhoto = hitsPhoto(event, stageRef.current?.querySelector("[data-current]") ?? null, pose);
    const caught = state.closing && onPhoto;
    if (state.closing && !caught) return;

    const drag: Drag = {
      pointer: event.pointerId,
      mode: "pending",
      onPhoto,
      startX: event.clientX,
      startY: event.clientY,
      samples: [{ x: event.clientX, y: event.clientY, t: event.timeStamp }],
      from: state.index,
      trackFrom: 0,
      centerX: 0,
      centerY: 0,
      grabX: 0,
      grabY: 0,
      scaleFrom: 1,
      backdropFrom: 1,
    };
    state.drag = drag;
    event.currentTarget.setPointerCapture(event.pointerId);

    if (caught) {
      // Caught on its way home: it's the user's again.
      state.closing = false;
      state.generation++;
      beginLift(drag, event);
    } else if (Math.abs(track.get() + state.index * pitch) > 1) {
      // Pressed while paging: catch the track and keep paging.
      beginPage(drag, event);
    }
  };

  const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
    const drag = live.current.drag;
    if (!drag || event.pointerId !== drag.pointer) return;
    record(drag, event);

    if (drag.mode === "pending") {
      const dx = event.clientX - drag.startX;
      const dy = event.clientY - drag.startY;
      if (Math.hypot(dx, dy) < LIGHTBOX.lock) return;
      if (count > 1 && Math.abs(dx) > Math.abs(dy)) beginPage(drag, event);
      else beginLift(drag, event);
    }

    if (drag.mode === "page") {
      const min = -Math.min(drag.from + 1, count - 1) * pitch;
      const max = -Math.max(drag.from - 1, 0) * pitch;
      const raw = drag.trackFrom + event.clientX - drag.startX;
      track.set(
        raw > max
          ? max + rubberband(raw - max, viewport.width)
          : raw < min
            ? min - rubberband(min - raw, viewport.width)
            : raw,
      );
      return;
    }

    const height = viewport.height;
    const dy = event.clientY - drag.startY;
    const down = Math.max(dy, 0);
    const size = drag.scaleFrom * Math.max(LIGHTBOX.minScale, 1 - down / (height * LIGHTBOX.shrink));
    // Up is the wrong way: let it give a little, then resist.
    const pointerY = dy < 0 ? drag.startY - rubberband(-dy, height) : event.clientY;
    x.set(event.clientX - drag.grabX * size - drag.centerX);
    y.set(pointerY - drag.grabY * size - drag.centerY);
    scale.set(size);
    radius.set(LIGHTBOX.lift * Math.min(down / 80, 1));
    backdrop.set(drag.backdropFrom * (1 - Math.min(down / (height * LIGHTBOX.reveal), 1)));
  };

  const onPointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
    const state = live.current;
    const drag = state.drag;
    if (!drag || event.pointerId !== drag.pointer) return;
    state.drag = null;
    const cancelled = event.type === "pointercancel";
    if (!cancelled) record(drag, event);
    const velocity = cancelled ? { x: 0, y: 0 } : velocityOf(drag.samples, event.timeStamp);

    if (drag.mode === "pending") {
      if (cancelled) return;
      if (drag.onPhoto) toggleChrome(event.pointerType === "mouse" ? "mouse" : "touch");
      else close();
      return;
    }

    if (drag.mode === "page") {
      const position = -track.get() / pitch;
      const next =
        Math.abs(velocity.x) > LIGHTBOX.flick
          ? velocity.x < 0
            ? Math.floor(position) + 1
            : Math.ceil(position) - 1
          : Math.round(-(track.get() + project(velocity.x)) / pitch);
      go(clamp(next, drag.from - 1, drag.from + 1), velocity.x, true);
      return;
    }

    // Decide by where the throw was heading, not where the finger stopped.
    const dy = event.clientY - drag.startY;
    const threshold = Math.max(96, viewport.height * LIGHTBOX.dismiss);
    const home =
      !cancelled &&
      velocity.y > -50 &&
      (velocity.y > LIGHTBOX.flick || dy + project(velocity.y) > threshold);
    if (home) close(velocity);
    else settle(velocity);
  };

  const onKeyDown = (event: React.KeyboardEvent<HTMLDialogElement>) => {
    const state = live.current;
    if (event.key.toLowerCase() === "h" && !event.metaKey && !event.ctrlKey && !event.altKey) {
      event.preventDefault();
      if (!state.closing) toggleChrome("key");
      return;
    }
    const keys: Record<string, number> = {
      ArrowLeft: state.index - 1,
      ArrowRight: state.index + 1,
      Home: 0,
      End: count - 1,
    };
    if (event.key in keys) {
      event.preventDefault();
      go(keys[event.key]);
    } else if (
      ["ArrowUp", "ArrowDown", "PageUp", "PageDown", " "].includes(event.key) &&
      !(event.target instanceof HTMLButtonElement)
    ) {
      event.preventDefault(); // don't scroll the page behind
    }
  };

  // Trackpad swipes page; nothing scrolls the page behind.
  const onWheel = React.useRef<(event: WheelEvent) => void>(() => {});
  React.useLayoutEffect(() => {
    onWheel.current = (event) => {
      event.preventDefault();
      const wheel = live.current.wheel;
      window.clearTimeout(wheel.timer);
      wheel.timer = window.setTimeout(() => {
        wheel.sum = 0;
        wheel.locked = false;
      }, 180);
      if (wheel.locked || count < 2 || Math.abs(event.deltaX) <= Math.abs(event.deltaY)) return;
      wheel.sum += event.deltaX;
      if (Math.abs(wheel.sum) > 40) {
        wheel.locked = true;
        go(live.current.index + Math.sign(wheel.sum));
      }
    };
  });
  React.useLayoutEffect(() => {
    const dialog = dialogRef.current;
    if (!dialog) return;
    const listener = (event: WheelEvent) => onWheel.current(event);
    dialog.addEventListener("wheel", listener, { passive: false });
    return () => dialog.removeEventListener("wheel", listener);
  }, []);

  const first = Math.max(Math.min(index, anchor) - 1, 0);
  const last = Math.min(Math.max(index, anchor) + 1, count - 1);
  const current = slides[index];
  const arrow = cn(
    "absolute top-1/2 -translate-y-1/2 disabled:pointer-events-none disabled:opacity-0",
    "max-sm:hidden pointer-coarse:hidden", // touch swipes instead
  );
  const control = cn(
    "grid size-10 place-items-center rounded-full bg-foreground/[0.06] text-foreground backdrop-blur-md outline-none",
    "transition-[background-color,scale,opacity] duration-150 ease-out hover:bg-foreground/10 active:scale-95",
    "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground",
    immersive ? "pointer-events-none" : "pointer-events-auto",
  );

  return (
    <dialog
      ref={dialogRef}
      aria-label="Photo viewer"
      onCancel={(event) => {
        event.preventDefault();
        close();
      }}
      onClose={finish}
      onKeyDown={onKeyDown}
      className="fixed inset-0 m-0 size-full max-h-none max-w-none overflow-hidden border-0 bg-transparent p-0 text-foreground outline-none backdrop:bg-transparent"
    >
      <motion.div
        aria-hidden
        className={cn("absolute inset-0 bg-background", overlayClassName)}
        style={{ opacity: backdrop }}
      />
      <motion.div aria-hidden className="absolute inset-0 bg-black" style={{ opacity: shadeOpacity }} />

      <div
        ref={stageRef}
        tabIndex={-1}
        className="absolute inset-0 cursor-zoom-out touch-none overscroll-none outline-none select-none"
        onPointerDown={onPointerDown}
        onPointerMove={onPointerMove}
        onPointerUp={onPointerUp}
        onPointerCancel={onPointerUp}
      >
        <motion.div className="absolute inset-0" style={{ x: track }}>
          {range(first, last).map((i) => (
            <Photo
              key={slides[i].id}
              slide={slides[i]}
              box={boxOf(i)}
              offset={i * pitch}
              width={viewport.width}
              pose={i === index ? { ...pose, clipPath } : undefined}
              onAspect={(aspect) =>
                setAspects((prev) =>
                  Math.abs((prev[slides[i].id] ?? slides[i].aspect) - aspect) < 0.01
                    ? prev
                    : { ...prev, [slides[i].id]: aspect },
                )
              }
            />
          ))}
        </motion.div>
      </div>

      <motion.div
        className="pointer-events-none absolute inset-0"
        style={{ opacity: chrome }}
        // Tabbing to a hidden control brings the controls back.
        onFocus={(event) => {
          if (immersive && event.target.matches(":focus-visible")) toggleChrome("key");
        }}
      >
        <div className="absolute inset-x-0 top-0 flex items-center justify-between gap-4 p-3 sm:p-4">
          <p
            aria-live="polite"
            className={cn(
              "rounded-full bg-foreground/[0.06] px-3 py-1.5 font-mono text-xs text-muted-foreground tabular-nums backdrop-blur-md",
              count < 2 && "invisible",
            )}
          >
            {index + 1} / {count}
          </p>
          <div className="relative flex items-center gap-2">
            <button
              type="button"
              aria-label="Hide controls"
              aria-keyshortcuts="H"
              aria-describedby={tooltipId}
              className={cn(control, "peer")}
              onClick={(event) => toggleChrome(sourceOf(event))}
            >
              <Icon path="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
            </button>
            <div
              id={tooltipId}
              role="tooltip"
              className={cn(
                "pointer-events-none absolute top-full right-12 mt-2 w-max rounded-lg bg-foreground px-2.5 py-1.5 text-xs text-background opacity-0 shadow-lg",
                "transition-opacity duration-150 peer-hover:opacity-100 peer-hover:delay-500 peer-focus-visible:opacity-100 pointer-coarse:hidden",
              )}
            >
              <span className="flex items-center gap-2 font-medium">
                Hide controls
                <kbd className="rounded bg-background/15 px-1 font-mono text-[10px] leading-4">H</kbd>
              </span>
              <span className="mt-0.5 block text-background/60">or click the photo</span>
            </div>
            <button
              type="button"
              aria-label="Close"
              className={control}
              onClick={() => close()}
            >
              <Icon path="M6 6l12 12M18 6L6 18" />
            </button>
          </div>
        </div>

        {count > 1 ? (
          <>
            <button
              type="button"
              aria-label="Previous photo"
              disabled={index === 0}
              className={cn(control, arrow, "left-5")}
              onClick={() => go(index - 1)}
            >
              <Icon path="M15 6l-6 6 6 6" />
            </button>
            <button
              type="button"
              aria-label="Next photo"
              disabled={index === count - 1}
              className={cn(control, arrow, "right-5")}
              onClick={() => go(index + 1)}
            >
              <Icon path="M9 6l6 6-6 6" />
            </button>
          </>
        ) : null}

        {current.caption ? (
          <div className="absolute inset-x-0 bottom-0 flex justify-center px-6 pb-4 sm:pb-6">
            <motion.p
              key={current.id}
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              transition={LIGHTBOX.fadeIn}
              className="line-clamp-2 max-w-prose text-center text-sm text-pretty text-muted-foreground"
            >
              {current.caption}
            </motion.p>
          </div>
        ) : null}
      </motion.div>

      {hintText ? (
        <motion.p
          aria-live="polite"
          className="pointer-events-none absolute inset-x-4 bottom-6 mx-auto w-fit rounded-full bg-white/10 px-3.5 py-1.5 text-center text-xs text-white/75 backdrop-blur-md"
          style={{ opacity: hint }}
        >
          {hintText}
        </motion.p>
      ) : null}
    </dialog>
  );
}

const STILL = { x: 0, y: 0, scale: 1, opacity: 1, clipPath: "none" };

function Photo({
  slide,
  box,
  offset,
  width,
  pose,
  onAspect,
}: {
  slide: Slide;
  box: Box;
  offset: number;
  width: number;
  pose?: {
    x: MotionValue<number>;
    y: MotionValue<number>;
    scale: MotionValue<number>;
    opacity: MotionValue<number>;
    clipPath: MotionValue<string>;
  };
  onAspect: (aspect: number) => void;
}) {
  const [loaded, setLoaded] = React.useState(false);
  const layered = Boolean(slide.thumbnail && slide.thumbnail !== slide.src);
  const style = pose
    ? { x: pose.x, y: pose.y, scale: pose.scale, opacity: pose.opacity, clipPath: pose.clipPath }
    : STILL;

  return (
    <div className="absolute inset-y-0" style={{ left: offset, width }}>
      <motion.div
        data-current={pose ? "" : undefined}
        className="absolute cursor-grab will-change-transform active:cursor-grabbing"
        style={{ left: box.x, top: box.y, width: box.width, height: box.height, ...style }}
      >
        {layered ? (
          <img
            src={slide.thumbnail}
            alt=""
            aria-hidden
            draggable={false}
            className="absolute inset-0 size-full object-cover"
          />
        ) : null}
        <img
          src={slide.src}
          alt={slide.alt}
          decoding="async"
          draggable={false}
          onLoad={(event) => {
            const image = event.currentTarget;
            setLoaded(true);
            if (image.naturalWidth && image.naturalHeight) {
              onAspect(image.naturalWidth / image.naturalHeight);
            }
          }}
          className={cn(
            "absolute inset-0 size-full object-cover transition-opacity duration-300",
            layered && !loaded && "opacity-0",
          )}
        />
      </motion.div>
    </div>
  );
}

function Icon({ path }: { path: string }) {
  return (
    <svg
      aria-hidden
      viewBox="0 0 24 24"
      className="size-[18px]"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.75}
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      <path d={path} />
    </svg>
  );
}

/** What pressed a button: a keyboard (no pointer), a finger or pen, or a mouse. */
function sourceOf(event: React.MouseEvent): HintSource {
  if (event.detail === 0) return "key";
  const type = (event.nativeEvent as Partial<PointerEvent>).pointerType;
  return type === "touch" || type === "pen" ? "touch" : "mouse";
}

function useLightbox() {
  const context = React.useContext(LightboxContext);
  if (!context) throw new Error("ZoomLightboxItem must be used inside <ZoomLightbox>.");
  return context;
}

// ── Geometry ──────────────────────────────────────────────

function readViewport(): Viewport {
  return {
    width: document.documentElement.clientWidth,
    height: document.documentElement.clientHeight,
  };
}

/** The largest box with this aspect ratio that fits the screen, centered. */
function fit(aspect: number, viewport: Viewport): Box {
  const inset = viewport.width < FRAME.narrow ? FRAME.inset : FRAME.wideInset;
  const maxWidth = Math.max(viewport.width - inset.x * 2, 1);
  const maxHeight = Math.max(viewport.height - inset.y * 2, 1);
  const width = Math.min(maxWidth, maxHeight * aspect);
  const height = width / aspect;
  return { x: (viewport.width - width) / 2, y: (viewport.height - height) / 2, width, height };
}

/**
 * The pose that makes the photo, laid out in `box`, look exactly like its
 * tile: scaled to cover the tile's image, cropped to the tile, with the
 * tile's corners. Null when the tile isn't on screen.
 */
function tilePose(entry: Entry | undefined, box: Box, viewport: Viewport): Pose | null {
  if (!entry?.button || !entry.image) return null;
  const tile = entry.button.getBoundingClientRect();
  const visible =
    tile.width > 0 &&
    tile.height > 0 &&
    tile.bottom > 0 &&
    tile.right > 0 &&
    tile.top < viewport.height &&
    tile.left < viewport.width;
  if (!visible) return null;

  // The tile's image may be scaled on hover; cover it the way object-cover does.
  const image = entry.image.getBoundingClientRect();
  const scale = Math.max(image.width / box.width, image.height / box.height);
  const centerX = image.left + image.width / 2;
  const centerY = image.top + image.height / 2;
  const photoLeft = centerX - (box.width * scale) / 2;
  const photoTop = centerY - (box.height * scale) / 2;

  return {
    x: centerX - (box.x + box.width / 2),
    y: centerY - (box.y + box.height / 2),
    scale,
    top: Math.max(tile.top - photoTop, 0) / scale,
    left: Math.max(tile.left - photoLeft, 0) / scale,
    bottom: Math.max(photoTop + box.height * scale - tile.bottom, 0) / scale,
    right: Math.max(photoLeft + box.width * scale - tile.right, 0) / scale,
    radius: parseFloat(getComputedStyle(entry.button).borderTopLeftRadius) || 0,
  };
}

/** Whether a press landed on the visible part of the photo. */
function hitsPhoto(
  event: React.PointerEvent,
  element: HTMLElement | null,
  pose: Record<"scale" | "top" | "right" | "bottom" | "left", MotionValue<number>>,
) {
  if (!element) return false;
  const rect = element.getBoundingClientRect();
  const s = pose.scale.get();
  return (
    event.clientX >= rect.left + pose.left.get() * s &&
    event.clientX <= rect.right - pose.right.get() * s &&
    event.clientY >= rect.top + pose.top.get() * s &&
    event.clientY <= rect.bottom - pose.bottom.get() * s
  );
}

function aspectOf(entry: Entry) {
  if (entry.width && entry.height) return entry.width / entry.height;
  const full = preloaded.get(entry.src);
  if (full?.naturalWidth) return full.naturalWidth / full.naturalHeight;
  if (entry.image?.naturalWidth) return entry.image.naturalWidth / entry.image.naturalHeight;
  const tile = entry.button?.getBoundingClientRect();
  return tile?.height ? tile.width / tile.height : 1;
}

// ── Helpers ───────────────────────────────────────────────

const preloaded = new Map<string, HTMLImageElement>();

function preload(src: string) {
  if (preloaded.has(src)) return;
  const image = new Image();
  image.decoding = "async";
  image.src = src;
  preloaded.set(src, image);
}

/** Hides the tile whose photo is open, so the photo reads as the same object. */
function hideTile(
  state: { hidden: HTMLElement | null },
  entries: Map<string, Entry>,
  id: string | null,
  reduceMotion: boolean,
) {
  if (state.hidden) state.hidden.style.opacity = "";
  state.hidden = null;
  // With reduced motion the photo fades instead of flying, so the tile stays.
  const image = id && !reduceMotion ? entries.get(id)?.image : null;
  if (!image) return;
  image.style.opacity = "0";
  state.hidden = image;
}

function record(drag: Drag, event: React.PointerEvent) {
  drag.samples.push({ x: event.clientX, y: event.clientY, t: event.timeStamp });
  while (drag.samples.length > 2 && event.timeStamp - drag.samples[0].t > 100) drag.samples.shift();
}

/** Pointer velocity over the last 80ms, in px/s. Zero if the finger had stopped. */
function velocityOf(samples: Drag["samples"], now: number) {
  const last = samples[samples.length - 1];
  if (!last || now - last.t > 80) return { x: 0, y: 0 };
  const first = samples.find((sample) => last.t - sample.t <= 80) ?? last;
  const dt = (last.t - first.t) / 1000;
  if (dt <= 0) return { x: 0, y: 0 };
  const cap = (v: number) => clamp(v, -4000, 4000);
  return { x: cap((last.x - first.x) / dt), y: cap((last.y - first.y) / dt) };
}

/** How much further a flick travels before it comes to rest, like a scroll view. */
function project(velocity: number, rate = 0.99) {
  return ((velocity / 1000) * rate) / (1 - rate);
}

/** Past a boundary, follow less and less the further it's pulled. */
function rubberband(overshoot: number, dimension: number, constant = 0.55) {
  return (overshoot * dimension * constant) / (dimension + constant * overshoot);
}

function clamp(value: number, min: number, max: number) {
  return Math.min(Math.max(value, min), max);
}

function range(from: number, to: number) {
  return Array.from({ length: Math.max(to - from + 1, 0) }, (_, i) => from + i);
}
```
