# Lightbox

A full-screen viewer that opens from its triggers, with mobile gestures and zoom.

```tsx title="hero.tsx"
"use client";
import * as React from "react";
import { Lightbox } from "@logic-ui/react/lightbox";
import { Toolbar } from "@logic-ui/react/toolbar";
import { useMediaQuery } from "@logic-ui/react/unstable-use-media-query";
import { cn } from "cn";
import { getThumbnailSrc, type MediaItem, videos } from "./_photos";
import {
  ChevronIcon,
  CloseIcon,
  PlayIcon,
  ZoomInIcon,
  ZoomOutIcon,
} from "./_icons";
import { classes } from "./_classes";

/** Tailwind classes of this demo. */
const local = {
  // One large tile and four small ones beside it. On phones, like Instagram's
  // Explore: three columns of squares, the large tile two by two, and five
  // small ones around it. The outer corners are on the tiles, not clipped by
  // the grid: the image flies out of each tile with its corners.
  Layout:
    "grid w-full max-w-xl grid-cols-3 gap-1 select-none sm:grid-cols-[2fr_1fr_1fr] sm:grid-rows-[repeat(2,8rem)] sm:gap-1.5",
  Tile: cn(
    "aspect-square first:col-span-2 first:row-span-2 first:aspect-auto sm:aspect-auto sm:first:col-span-1",
    "first:rounded-tl-2xl nth-2:rounded-tr-2xl nth-4:rounded-bl-2xl nth-6:rounded-br-2xl",
    "sm:first:rounded-l-2xl sm:nth-2:rounded-none sm:nth-3:rounded-tr-2xl sm:nth-4:rounded-none sm:nth-5:rounded-br-2xl",
    // The page is rendered with the phone's six tiles, until the media query
    // is read: on larger screens, the sixth waits hidden.
    "sm:nth-6:hidden",
  ),
  // Over the last tile: how many items the grid doesn't show.
  More: "absolute inset-0 flex items-center justify-center bg-black/55 text-2xl font-semibold text-white tabular-nums backdrop-blur-[2px] transition-colors duration-300 group-hover:bg-black/45",
  // Marks a video tile.
  Badge:
    "absolute bottom-2 left-2 flex size-6 items-center justify-center rounded-full bg-black/60 text-white backdrop-blur-md",
  // Room below the media for the caption and the strip.
  Slide:
    "px-[max(1rem,env(safe-area-inset-left))] pt-[calc(4.5rem+env(safe-area-inset-top))] pb-[calc(11rem+env(safe-area-inset-bottom))] md:px-24",
  // Not on phones: the toolbar needs the room, and the strip shows where you are.
  Counter: `${classes.counter} max-sm:hidden`,
  Actions: "ms-auto flex items-center gap-2",
  // As tall as the close button, so the top bar lines up.
  Toolbar:
    "flex h-10 items-center gap-0.5 rounded-full border border-white/10 bg-neutral-900/80 px-px backdrop-blur-xl",
  Media: "group",
  Video: "bg-black",
  Text: "flex min-h-11 flex-col items-center text-center",
  Title: `m-0 text-[0.9375rem] font-semibold ${classes.enter}`,
  Description: `m-0 text-[0.8125rem] text-neutral-400 ${classes.enter}`,
  // Positioned, so the ring is placed in it. `px-[50%]` lets the first and
  // last thumbnails reach the centre too.
  Strip: `relative flex max-w-full gap-2 overflow-x-auto px-[50%] py-1.5 ${classes.hideScrollbar} ${classes.fadeX}`,
  Thumbnail:
    "relative size-12 flex-none cursor-pointer overflow-hidden rounded-lg border-none bg-white/10 p-0 opacity-50 transition-opacity duration-300 hover:opacity-80 data-active:opacity-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white",
  ThumbnailBadge:
    "absolute inset-0 m-auto size-3.5 text-white drop-shadow-[0_1px_2px_rgb(0_0_0/0.6)]",
  Ring: `${classes.indicator} rounded-lg`,
  ZoomReset: `${classes.toolbarValue} h-9 cursor-pointer rounded-full border-none bg-transparent px-2 transition-colors hover:not-data-disabled:bg-white/12 hover:not-data-disabled:text-white data-disabled:cursor-default focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-white`,
  // The shortcuts, on large screens with a mouse, set in from the corner,
  // with even padding.
  Legend:
    "absolute bottom-[calc(1.5rem+env(safe-area-inset-bottom))] left-[max(1.5rem,env(safe-area-inset-left))] m-0 hidden flex-col gap-2 rounded-2xl border border-white/10 bg-neutral-900/70 p-3 text-xs backdrop-blur-xl lg:pointer-fine:flex",
  LegendRow:
    "flex items-center gap-3 [&_dd]:m-0 [&_dd]:text-neutral-400 [&_dt]:flex [&_dt]:min-w-16 [&_dt]:gap-1",
  Key: "min-w-5 rounded-md border border-white/15 bg-white/5 px-1.5 py-0.5 text-center font-[inherit] text-[0.6875rem]",
};

/** A Picsum photo, cropped to `width` × `height`. */
function photo(
  id: string,
  picsumId: number,
  width: number,
  height: number,
  alt: string,
  caption: string,
): MediaItem {
  return {
    id,
    type: "image",
    src: `https://picsum.photos/id/${picsumId}/${width}/${height}`,
    width,
    height,
    alt,
    caption,
  };
}

/** Cars and houses, and a video first, for the large tile. */
const items: MediaItem[] = [
  videos[1],
  photo(
    "vintage-car",
    111,
    1600,
    1067,
    "A vintage car on a city street",
    "Farmer boy",
  ),
  photo(
    "green-door",
    946,
    1067,
    1600,
    "A green door in a yellow house",
    "Number 2",
  ),
  photo("camper", 655, 1600, 1067, "A blue camper van at dusk", "Blue camper"),
  photo("santorini", 49, 1600, 1067, "White houses of Santorini", "Santorini"),
  photo(
    "garage",
    133,
    1600,
    1067,
    "Two classic cars before yellow garage doors",
    "Twins",
  ),
  videos[0],
  photo("canal", 164, 1600, 1067, "Houses along a canal", "Canal houses"),
  photo("truck", 1072, 1600, 1067, "A rusty truck in a field", "Retired"),
  photo(
    "off-roader",
    514,
    1067,
    1600,
    "A white off-roader by a wooden house",
    "Off-roader",
  ),
  photo("shed", 76, 1600, 1067, "A bicycle against a wooden house", "The shed"),
  photo(
    "grille",
    1071,
    1600,
    1600,
    "The chrome grille of a classic car",
    "Chrome",
  ),
  photo("van", 183, 1600, 1067, "A camper van in a car park", "Parked"),
];

/**
 * How many tiles the grid shows; the last one counts the rest. Six on
 * phones, where the large tile is two columns wide, so the grid is full.
 * Counted here, not hidden with CSS: a closing image flies to the nearest
 * tile, and a hidden one has nowhere to land.
 */
function useTileCount(): number {
  return useMediaQuery("(min-width: 40rem)", {}) ? 5 : 6;
}

const shortcuts = [
  ["← →", "Previous / next"],
  ["+ −", "Zoom"],
  ["0", "Reset zoom"],
  ["Esc", "Close"],
  ["Drag", "Swipe, or close"],
];

/**
 * A still of an item: the image's thumbnail, or a frame of the video. `lazy`
 * for the ones out of sight when the page loads.
 */
function Preview(props: {
  item: MediaItem;
  large?: boolean;
  lazy?: boolean;
  className: string;
}) {
  const { item, large, lazy, className } = props;
  if (item.type === "video") {
    return (
      <video
        src={`${item.src}#t=1`}
        muted
        playsInline
        preload="metadata"
        className={className}
      />
    );
  }
  return (
    <img
      loading={lazy ? "lazy" : undefined}
      src={large ? item.src : getThumbnailSrc(item)}
      alt=""
      className={className}
    />
  );
}

export default function ExampleLightbox() {
  const tiles = useTileCount();
  const hiddenCount = items.length - tiles;

  return (
    // The items without a tile fly back into the tile with the count, the
    // nearest one, and dissolve into it.
    <Lightbox.Root items={items} closeTarget="nearest">
      <div className={local.Layout}>
        {items.slice(0, tiles).map((item, index) => {
          const isLast = index === tiles - 1;
          return (
            <Lightbox.Trigger
              key={item.id}
              value={item.id}
              // The count and the play badge aren't in the viewer: they fade
              // out as the image leaves the tile, and back in as it lands.
              crossfade={isLast || item.type === "video"}
              aria-label={
                isLast
                  ? `Open ${item.alt}, and ${hiddenCount} more`
                  : `Open ${item.alt}`
              }
              className={`${classes.trigger} ${local.Tile}`}
            >
              <Preview
                item={item}
                large={index === 0}
                className={classes.triggerImage}
              />
              {isLast ? (
                <span className={local.More} aria-hidden>
                  +{hiddenCount}
                </span>
              ) : index === 4 ? (
                // The count of the larger screens' five tiles, for while the
                // phone's six are rendered.
                <span className={cn(local.More, "max-sm:hidden")} aria-hidden>
                  +{items.length - 5}
                </span>
              ) : (
                item.type === "video" && (
                  <span className={local.Badge}>
                    <PlayIcon className="size-3" />
                  </span>
                )
              )}
            </Lightbox.Trigger>
          );
        })}
      </div>

      <Lightbox.Portal>
        <Lightbox.Backdrop className={classes.backdrop} />
        <Lightbox.Popup className={classes.popup}>
          <Lightbox.Viewport className={classes.viewport}>
            {(item: MediaItem) => (
              <Lightbox.Slide className={local.Slide}>
                {/* A video plays in place, pauses when you swipe away, and doesn't zoom. */}
                <Lightbox.Media
                  width={item.width}
                  height={item.height}
                  className={local.Media}
                >
                  {item.type === "video" ? (
                    <video
                      src={item.src}
                      controls
                      playsInline
                      preload="metadata"
                      className={local.Video}
                    />
                  ) : (
                    <img src={item.src} alt={item.alt} />
                  )}
                  <span className={classes.spinner} aria-hidden />
                </Lightbox.Media>
              </Lightbox.Slide>
            )}
          </Lightbox.Viewport>

          <header className={`${classes.topBar} ${classes.controlTop}`}>
            <Lightbox.Counter className={local.Counter} />
            <div className={local.Actions}>
              <Toolbar.Root className={local.Toolbar}>
                <Lightbox.Previous
                  render={<Toolbar.Button />}
                  className={classes.toolbarButton}
                >
                  <ChevronIcon direction="left" />
                </Lightbox.Previous>
                <Toolbar.Separator className={classes.separator} />
                {/* Disabled on a video: only images zoom. */}
                <Lightbox.ZoomOut
                  render={<Toolbar.Button />}
                  className={classes.toolbarButton}
                >
                  <ZoomOutIcon />
                </Lightbox.ZoomOut>
                {/* The scale, which resets the zoom when pressed. */}
                <Lightbox.ZoomReset
                  render={<Toolbar.Button />}
                  className={local.ZoomReset}
                >
                  <Lightbox.ZoomValue />
                </Lightbox.ZoomReset>
                <Lightbox.ZoomIn
                  render={<Toolbar.Button />}
                  className={classes.toolbarButton}
                >
                  <ZoomInIcon />
                </Lightbox.ZoomIn>
                <Toolbar.Separator className={classes.separator} />
                <Lightbox.Next
                  render={<Toolbar.Button />}
                  className={classes.toolbarButton}
                >
                  <ChevronIcon direction="right" />
                </Lightbox.Next>
              </Toolbar.Root>
              <Lightbox.Close className={classes.iconButton}>
                <CloseIcon />
              </Lightbox.Close>
            </div>
          </header>

          <footer className={`${classes.bottomBar} ${classes.controlBottom}`}>
            {/* Function children follow the item shown, and slide in when it changes. */}
            <div className={local.Text}>
              <Lightbox.Title className={local.Title}>
                {(item: MediaItem) => item.caption ?? "Untitled"}
              </Lightbox.Title>
              <Lightbox.Description className={local.Description}>
                {(item: MediaItem) => item.alt}
              </Lightbox.Description>
            </div>
            <Lightbox.ThumbnailList className={local.Strip}>
              {(item: MediaItem) => (
                <Lightbox.Thumbnail
                  aria-label={item.alt}
                  className={local.Thumbnail}
                >
                  <Preview
                    item={item}
                    lazy
                    className="size-full object-cover"
                  />
                  {item.type === "video" && (
                    <PlayIcon className={local.ThumbnailBadge} />
                  )}
                </Lightbox.Thumbnail>
              )}
              {/* One ring that glides to the active thumbnail. */}
              <Lightbox.ThumbnailIndicator className={local.Ring} />
            </Lightbox.ThumbnailList>
          </footer>

          <dl className={`${local.Legend} ${classes.controlBottom}`}>
            {shortcuts.map(([keys, action]) => (
              <div key={keys} className={local.LegendRow}>
                <dt>
                  {keys.split(" ").map((key) => (
                    <kbd key={key} className={local.Key}>
                      {key}
                    </kbd>
                  ))}
                </dt>
                <dd>{action}</dd>
              </div>
            ))}
          </dl>
        </Lightbox.Popup>
      </Lightbox.Portal>
    </Lightbox.Root>
  );
}
```

```ts title="_photos.ts"
/** A photo of the demos. The lightbox reads its `id`; the demos pass the rest to the parts. */
export type Photo = {
  id: string;
  src: string;
  alt: string;
  width?: number;
  height?: number;
  caption?: string;
};

/** Photos of different shapes, shared by the lightbox demos. */
export const photos: Photo[] = [
  {
    id: "valley",
    src: "https://picsum.photos/id/1015/1600/1067",
    width: 1600,
    height: 1067,
    alt: "River valley between cliffs",
    caption: "River valley",
  },
  {
    id: "portrait",
    src: "https://picsum.photos/id/1027/1067/1600",
    width: 1067,
    height: 1600,
    alt: "Portrait of a woman",
    caption: "A portrait, taller than wide",
  },
  {
    id: "waterfall",
    src: "https://picsum.photos/id/1035/1600/1600",
    width: 1600,
    height: 1600,
    alt: "Waterfall in a forest",
    caption: "A square image",
  },
  {
    id: "panorama",
    src: "https://picsum.photos/id/1043/2400/1000",
    width: 2400,
    height: 1000,
    alt: "Panorama of a valley",
    caption: "A wide panorama",
  },
  {
    id: "coast",
    src: "https://picsum.photos/id/1050/1600/1067",
    width: 1600,
    height: 1067,
    alt: "Rocky coast",
  },
  {
    id: "jellyfish",
    src: "https://picsum.photos/id/1069/1600/1067",
    width: 1600,
    height: 1067,
    alt: "Jellyfish in dark water",
    caption: "Jellyfish",
  },
  {
    id: "bridge",
    src: "https://picsum.photos/id/1067/1067/1600",
    width: 1067,
    height: 1600,
    alt: "City street",
  },
  {
    id: "mountains",
    src: "https://picsum.photos/id/1018/1600/1067",
    width: 1600,
    height: 1067,
    alt: "Mountains under a cloudy sky",
    caption: "Mountains",
  },
];

/** A smaller version of a photo, for thumbnails. */
export function getThumbnailSrc(photo: Photo) {
  return photo.src.replace(
    /\/(\d+)\/(\d+)$/,
    (_, width: string, height: string) => {
      const scale = 240 / Math.max(Number(width), Number(height));
      return `/${Math.round(Number(width) * scale)}/${Math.round(Number(height) * scale)}`;
    },
  );
}

/** An image that always fails to load, at once and without a request. */
export const BROKEN_SRC = "data:image/png;base64,broken";

/** A photo or a video of the demos that mix both. */
export type MediaItem = Photo & { type: "image" | "video" };

/** Short public videos: a CC0 clip from MDN's examples, and Blender's open movie. */
export const videos: MediaItem[] = [
  {
    id: "flower",
    type: "video",
    src: "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4",
    alt: "A flower opening",
    caption: "Blooming",
  },
  {
    id: "bunny",
    type: "video",
    src: "https://test-videos.co.uk/vids/bigbuckbunny/mp4/h264/720/Big_Buck_Bunny_720_10s_1MB.mp4",
    width: 1280,
    height: 720,
    alt: "Big Buck Bunny, © Blender Foundation, CC BY 3.0",
    caption: "Big Buck Bunny",
  },
];
```

```tsx title="_icons.tsx"
import * as React from "react";

type IconProps = React.ComponentProps<"svg">;

/** A 16 px stroke icon: the shape is drawn by its children. */
function Icon(props: IconProps) {
  return (
    <svg
      width="16"
      height="16"
      viewBox="0 0 16 16"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.5"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden
      {...props}
    />
  );
}

export function CloseIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <path d="M4 4 12 12M12 4 4 12" />
    </Icon>
  );
}

export function ChevronIcon(
  props: IconProps & { direction: "left" | "right" },
) {
  const { direction, ...iconProps } = props;
  return (
    <Icon {...iconProps}>
      <path d={direction === "left" ? "M10 3 5 8l5 5" : "m6 3 5 5-5 5"} />
    </Icon>
  );
}

export function ZoomInIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <circle cx="7" cy="7" r="4.5" />
      <path d="M7 5v4M5 7h4M10.5 10.5 14 14" />
    </Icon>
  );
}

export function ZoomOutIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <circle cx="7" cy="7" r="4.5" />
      <path d="M5 7h4M10.5 10.5 14 14" />
    </Icon>
  );
}

export function ResetIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <path d="M3 8a5 5 0 1 0 1.5-3.5M3 2.5V5h2.5" />
    </Icon>
  );
}

export function TrashIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <path d="M2.5 4h11M6 4V2.5h4V4M4 4l.7 9.5h6.6L12 4M6.5 7v4M9.5 7v4" />
    </Icon>
  );
}

export function PlusIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <path d="M8 3v10M3 8h10" />
    </Icon>
  );
}

export function PlayIcon(props: IconProps) {
  return (
    <svg
      width="16"
      height="16"
      viewBox="0 0 16 16"
      fill="currentColor"
      aria-hidden
      {...props}
    >
      <path d="M5 3.2v9.6a.5.5 0 0 0 .76.43l7.7-4.8a.5.5 0 0 0 0-.86l-7.7-4.8A.5.5 0 0 0 5 3.2Z" />
    </svg>
  );
}

export function PauseIcon(props: IconProps) {
  return (
    <svg
      width="16"
      height="16"
      viewBox="0 0 16 16"
      fill="currentColor"
      aria-hidden
      {...props}
    >
      <rect x="4" y="3" width="2.75" height="10" rx="0.75" />
      <rect x="9.25" y="3" width="2.75" height="10" rx="0.75" />
    </svg>
  );
}

/** Outlined; filled with `fill="currentColor"` once liked. */
export function HeartIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <path d="M8 13.5S2 10 2 6a3 3 0 0 1 6-1 3 3 0 0 1 6 1c0 4-6 7.5-6 7.5Z" />
    </Icon>
  );
}

export function SendIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <path d="M14 2 7 9M14 2l-4.5 12L7 9 2 6.5 14 2Z" />
    </Icon>
  );
}

/** A picture with a crack, for images that failed to load. */
export function BrokenImageIcon(props: IconProps) {
  return (
    <svg
      width="24"
      height="24"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.5"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden
      {...props}
    >
      <path d="M21 12V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v7" />
      <path d="m3 16 4-4 3 3 3-3" />
      <path d="M3 19a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-3l-3-3" />
      <path d="m3 3 18 18" />
    </svg>
  );
}
```

```ts title="_classes.ts"
/**
 * The controls of a viewer: they fade and slide off the edge of the screen
 * they sit on while the image is pulled to dismiss (`--lightbox-dismiss-progress`
 * goes from 0 to 1), and the same way when a tap hides them or the viewer
 * opens and closes. Transitions are off during a drag so they follow the finger.
 * Closing keeps at least the dragged offset, so they never reverse toward the viewer.
 * They take the flight's length and curve, so they arrive with the image,
 * even after a fast flick that closes with a spring.
 */
const control =
  "opacity-[calc(1_-_var(--lightbox-dismiss-progress,0)*1.5)] transition-[opacity,translate] duration-(--lightbox-flight-duration) ease-(--lightbox-flight-easing) in-data-swiping:transition-none in-data-controls-hidden:pointer-events-none in-data-controls-hidden:opacity-0 in-data-starting-style:opacity-0 in-data-ending-style:pointer-events-none in-data-ending-style:opacity-0 motion-reduce:translate-none";

/** Tailwind classes shared by the lightbox demos. */
export const classes = {
  // The page around the triggers.
  stack: "flex w-full flex-col items-center gap-4 select-none",
  text: "m-0 text-sm text-neutral-400",
  button:
    "flex h-8 cursor-pointer items-center justify-center gap-2 rounded-lg border border-white/10 bg-white/5 px-3 text-sm whitespace-nowrap text-white select-none hover:not-disabled:bg-white/10 disabled:cursor-default disabled:opacity-40 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white",

  // Triggers. While the viewer shows a trigger's image, hide the trigger but
  // keep its space (`invisible`): the image looks like it left its slot.
  trigger:
    "group relative cursor-pointer overflow-hidden border-none bg-white/5 p-0 data-flying:invisible data-popup-open:invisible focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white",
  triggerImage:
    "size-full object-cover transition-opacity duration-300 group-data-pending:opacity-60",

  // Viewer.
  backdrop:
    "fixed inset-0 bg-[rgb(8_8_12/calc(0.92*(1_-_var(--lightbox-dismiss-progress,0))))] backdrop-blur-[calc(20px*(1_-_var(--lightbox-dismiss-progress,0)))] transition-[background-color,backdrop-filter] duration-(--lightbox-flight-duration) ease-(--lightbox-flight-easing) data-swiping:transition-none data-starting-style:bg-transparent data-starting-style:backdrop-blur-none data-ending-style:bg-transparent data-ending-style:backdrop-blur-none",
  popup: "fixed inset-0 text-white select-none outline-none",
  viewport:
    "absolute inset-0 cursor-default gap-x-4 select-none in-data-zoomed:cursor-grab in-data-zoomed:data-swiping:cursor-grabbing",
  // Room for the controls above and below the image, and for notches.
  slide:
    "px-[max(1rem,env(safe-area-inset-left))] pt-[calc(4.5rem+env(safe-area-inset-top))] pb-[calc(5.5rem+env(safe-area-inset-bottom))] md:px-20",

  /** A control at the top: it slides up and away. */
  controlTop: `${control} -translate-y-[calc((100%+1rem)*1.5*var(--lightbox-dismiss-progress,0))] in-data-controls-hidden:-translate-y-4 in-data-starting-style:-translate-y-4 in-data-ending-style:-translate-y-[max(1rem,calc((100%+1rem)*1.5*var(--lightbox-dismiss-progress,0)))]`,
  /** A control at the bottom: it slides down and away. */
  controlBottom: `${control} translate-y-[calc((100%+1rem)*1.5*var(--lightbox-dismiss-progress,0))] in-data-controls-hidden:translate-y-4 in-data-starting-style:translate-y-4 in-data-ending-style:translate-y-[max(1rem,calc((100%+1rem)*1.5*var(--lightbox-dismiss-progress,0)))]`,
  /** A control on the right edge: it slides right and away. */
  controlRight: `${control} translate-x-[calc((100%+1rem)*1.5*var(--lightbox-dismiss-progress,0))] in-data-controls-hidden:translate-x-4 in-data-starting-style:translate-x-4 in-data-ending-style:translate-x-[max(1rem,calc((100%+1rem)*1.5*var(--lightbox-dismiss-progress,0)))]`,

  topBar:
    "absolute inset-x-0 top-0 flex items-center justify-between gap-3 px-[max(0.75rem,env(safe-area-inset-left))] pt-[calc(0.75rem+env(safe-area-inset-top))]",
  bottomBar:
    "absolute inset-x-0 bottom-0 flex flex-col items-center gap-3 px-3 pt-10 pb-[calc(1rem+env(safe-area-inset-bottom))]",
  // A soft shade behind controls over the image, so they stay readable.
  shadeTop: "bg-linear-to-b from-black/60 to-transparent pb-8",
  shadeBottom: "bg-linear-to-t from-black/70 to-transparent",
  iconButton:
    "flex size-10 cursor-pointer items-center justify-center rounded-full border-none bg-white/10 text-white backdrop-blur-md transition-colors hover:not-data-disabled:bg-white/20 data-disabled:cursor-default data-disabled:opacity-35 focus-visible:outline-2 focus-visible:outline-white",
  // As tall as the icon buttons, its text as far from the side as from the top.
  counter:
    "flex h-10 items-center px-2.5 text-sm font-medium whitespace-nowrap text-white/90 tabular-nums",
  toolbar:
    "flex max-w-full items-center gap-0.5 overflow-x-auto rounded-full border border-white/10 bg-neutral-900/80 p-1 shadow-2xl backdrop-blur-xl",
  toolbarButton:
    "flex size-9 flex-none cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-white transition-colors hover:not-data-disabled:bg-white/12 data-disabled:cursor-default data-disabled:opacity-30 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-white",
  toolbarValue:
    "min-w-14 text-center text-[0.8125rem] text-neutral-400 tabular-nums",
  separator: "mx-1 h-4 w-px flex-none bg-white/15",

  /**
   * New text slides in from the side the photo came from:
   * `data-starting-style` lasts one frame, then the transition brings it in.
   * The element stays on screen, so jump to the start without a transition.
   */
  enter:
    "transition-[opacity,translate] duration-300 ease-out data-starting-style:opacity-0 data-starting-style:transition-none data-starting-style:data-[activation-direction=left]:-translate-x-5 data-starting-style:data-[activation-direction=right]:translate-x-5 motion-reduce:transition-none",

  // Thumbnail strips: they still scroll, only the browser's scrollbar is hidden.
  hideScrollbar: "[scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
  fadeX:
    "data-overflow-start:mask-l-from-85% data-overflow-end:mask-r-from-85%",
  fadeY:
    "data-overflow-start:mask-t-from-85% data-overflow-end:mask-b-from-85%",
  // A ring on the active thumbnail. No transition: its variables move with
  // the slides on every frame (the drag, the settle after a flick, Next), so
  // it lands exactly when they do.
  indicator:
    "pointer-events-none absolute top-(--active-thumbnail-top) left-(--active-thumbnail-left) h-(--active-thumbnail-height) w-(--active-thumbnail-width) outline-2 outline-offset-2 outline-white",

  // Loading and errors, inside `Lightbox.Media`.
  spinner:
    "absolute inset-0 m-auto hidden size-8 animate-spin rounded-full border-2 border-white/20 border-t-white group-data-loading:block",
};
```

## Usage guidelines

- **The images fly:** the image grows out of its trigger when the viewer opens and flies back when it closes, to its own trigger or to the one `closeTarget` picks, such as a "+5" tile. Hide the trigger while it has `data-popup-open`.
- **One gesture engine:** swipe, pinch, double tap, pan and pull to dismiss are handled together, so they never fight over a touch. A long drag can cross multiple slides and settles at the position reached, rather than advancing only one. The viewer needs no carousel or zoom library.
- **Your layout, your items:** the lightbox renders no thumbnails of its own. Lay out your page however you like and place a `Lightbox.Trigger` per item. Items can have any shape; the lightbox only needs a stable value for each.
- **No dependencies:** the lightbox needs no carousel, zoom, animation or image library, and no framework. `Lightbox.Media` wraps your own `<img>`, `<video>` or `<iframe>`; give it any `src` or `srcSet`.

## Anatomy

Import the component and assemble its parts:

```jsx title="Anatomy"
<Lightbox.Root>
  <Lightbox.Trigger />

  <Lightbox.Portal>
    <Lightbox.Backdrop />
    <Lightbox.Popup>
      <Lightbox.Title />
      <Lightbox.Description />

      <Lightbox.Viewport>
        <Lightbox.Slide>
          <Lightbox.Media />
          <Lightbox.Caption />
        </Lightbox.Slide>
      </Lightbox.Viewport>

      <Lightbox.Previous />
      <Lightbox.Next />

      <Lightbox.Counter />
      <Lightbox.ZoomOut />
      <Lightbox.ZoomValue />
      <Lightbox.ZoomIn />
      <Lightbox.ZoomReset />
      <Lightbox.Close />

      <Lightbox.ThumbnailList>
        <Lightbox.Thumbnail />
        <Lightbox.ThumbnailIndicator />
      </Lightbox.ThumbnailList>
    </Lightbox.Popup>
  </Lightbox.Portal>
</Lightbox.Root>
```

## Examples

### Getting started

The smallest viewer: triggers, the images and a close button. Swipe, pinch, double tap, pull to dismiss and the keyboard all work.

```tsx title="minimal.tsx"
"use client";
import * as React from "react";
import { Lightbox } from "@logic-ui/react/lightbox";
import { getThumbnailSrc, photos, type Photo } from "./_photos";
import { CloseIcon } from "./_icons";
import { classes } from "./_classes";

const items = photos.slice(0, 4);

/** Only the parts a viewer needs: swipe, zoom, pull to dismiss and Esc still work. */
export default function ExampleLightboxMinimal() {
  return (
    <Lightbox.Root items={items}>
      {/* Three columns across a phone, a row of squares on larger screens. */}
      <div className="grid w-full grid-cols-3 gap-2 select-none sm:flex sm:w-auto sm:flex-wrap sm:items-center">
        {items.map((photo) => (
          <Lightbox.Trigger
            key={photo.id}
            value={photo.id}
            className={`${classes.trigger} aspect-square w-full rounded-xl sm:size-24`}
          >
            <img
              loading="lazy"
              src={getThumbnailSrc(photo)}
              alt=""
              className={classes.triggerImage}
            />
          </Lightbox.Trigger>
        ))}
      </div>

      <Lightbox.Portal>
        <Lightbox.Backdrop className={classes.backdrop} />
        <Lightbox.Popup className={classes.popup}>
          <Lightbox.Title className="sr-only">Photos</Lightbox.Title>
          <Lightbox.Viewport className={classes.viewport}>
            {(photo: Photo) => (
              <Lightbox.Slide className={classes.slide}>
                <Lightbox.Media width={photo.width} height={photo.height}>
                  <img src={photo.src} alt={photo.alt} />
                </Lightbox.Media>
              </Lightbox.Slide>
            )}
          </Lightbox.Viewport>
          <div className={`${classes.topBar} ${classes.controlTop}`}>
            <Lightbox.Close className={`${classes.iconButton} ms-auto`}>
              <CloseIcon />
            </Lightbox.Close>
          </div>
        </Lightbox.Popup>
      </Lightbox.Portal>
    </Lightbox.Root>
  );
}
```

`Lightbox.Root` takes the items in `items`, with any shape. Each item needs a value that identifies it and stays the same when items are added, removed or reordered: by default its `id`, or whatever `itemToValue` returns. `Lightbox.Trigger` opens the viewer on the item with its `value`. `Lightbox.Viewport` calls its function child for the item shown and its neighbours; the parts take what they need as props. Your `<img>` gets its `src` and `alt`, and `Lightbox.Media` gets the natural `width` and `height`, which give it the right shape before the image loads.

```tsx title="Items"
const photos = [
  { id: "1", src: "/photos/1.jpg", width: 1600, height: 1067, alt: "Valley" },
  { id: "2", src: "/photos/2.jpg", alt: "Portrait" },
];

<Lightbox.Root items={photos}>
  {photos.map((photo) => (
    <Lightbox.Trigger key={photo.id} value={photo.id}>
      <img src={photo.thumb} alt="" />
    </Lightbox.Trigger>
  ))}
  <Lightbox.Portal>
    <Lightbox.Backdrop />
    <Lightbox.Popup>
      <Lightbox.Viewport>
        {(photo) => (
          <Lightbox.Slide>
            <Lightbox.Media width={photo.width} height={photo.height}>
              <img src={photo.src} alt={photo.alt} />
            </Lightbox.Media>
          </Lightbox.Slide>
        )}
      </Lightbox.Viewport>
      <Lightbox.Close />
    </Lightbox.Popup>
  </Lightbox.Portal>
</Lightbox.Root>;

// Items without an `id`:
<Lightbox.Root items={files} itemToValue={(file) => file.path}>
  …
</Lightbox.Root>;
```

### A single image

One trigger opens one image, here a profile photo with the zoom only. The image grows out of the circle: the flight takes each corner radius of its trigger. Round the triggers themselves (`first:rounded-l-xl`), not a container that clips them (`overflow-hidden rounded-xl`): the flight can't see a parent's clip. The gallery at the top of the page does the same with its corner tiles.

```tsx title="single-image.tsx"
"use client";
import * as React from "react";
import { Lightbox } from "@logic-ui/react/lightbox";
import { Toolbar } from "@logic-ui/react/toolbar";
import { photos, type Photo } from "./_photos";
import { CloseIcon, ResetIcon, ZoomInIcon, ZoomOutIcon } from "./_icons";
import { classes } from "./_classes";

/** Tailwind classes of this demo. */
const local = {
  Profile: "flex items-center gap-3 select-none",
  // The image grows out of the circle: the flight takes the trigger's radius.
  Avatar: "size-14 rounded-full",
  Name: "m-0 text-sm font-semibold text-white",
  Role: "m-0 text-sm text-neutral-400",
  Heading: "m-0 text-sm font-medium",
};

const portrait = { ...photos[1], caption: "Maya Lindqvist" };
const items = [portrait];

export default function ExampleLightboxSingleImage() {
  return (
    <Lightbox.Root items={items}>
      <div className={local.Profile}>
        <Lightbox.Trigger
          value={portrait.id}
          aria-label="View Maya's photo"
          className={`${classes.trigger} ${local.Avatar}`}
        >
          <img
            loading="lazy"
            src={portrait.src}
            alt=""
            className={classes.triggerImage}
          />
        </Lightbox.Trigger>
        <div>
          <p className={local.Name}>Maya Lindqvist</p>
          <p className={local.Role}>Photographer</p>
        </div>
      </div>

      <Lightbox.Portal>
        <Lightbox.Backdrop className={classes.backdrop} />
        <Lightbox.Popup className={classes.popup}>
          <Lightbox.Viewport className={classes.viewport}>
            {(photo: Photo) => (
              <Lightbox.Slide className={classes.slide}>
                <Lightbox.Media width={photo.width} height={photo.height}>
                  <img src={photo.src} alt={photo.alt} />
                </Lightbox.Media>
              </Lightbox.Slide>
            )}
          </Lightbox.Viewport>
          <header className={`${classes.topBar} ${classes.controlTop}`}>
            <Lightbox.Title className={local.Heading}>
              {(photo: Photo) => photo.caption}
            </Lightbox.Title>
            <Lightbox.Close className={classes.iconButton}>
              <CloseIcon />
            </Lightbox.Close>
          </header>
          {/* One photo: only the zoom. */}
          <footer className={`${classes.bottomBar} ${classes.controlBottom}`}>
            <Toolbar.Root className={classes.toolbar}>
              <Lightbox.ZoomOut
                render={<Toolbar.Button />}
                className={classes.toolbarButton}
              >
                <ZoomOutIcon />
              </Lightbox.ZoomOut>
              <Lightbox.ZoomValue className={classes.toolbarValue} />
              <Lightbox.ZoomIn
                render={<Toolbar.Button />}
                className={classes.toolbarButton}
              >
                <ZoomInIcon />
              </Lightbox.ZoomIn>
              <Toolbar.Separator className={classes.separator} />
              <Lightbox.ZoomReset
                render={<Toolbar.Button />}
                className={classes.toolbarButton}
              >
                <ResetIcon />
              </Lightbox.ZoomReset>
            </Toolbar.Root>
          </footer>
        </Lightbox.Popup>
      </Lightbox.Portal>
    </Lightbox.Root>
  );
}
```

### Chat attachments

One lightbox for the whole conversation: swipe from one message's images to the next. The last tile of a message counts the images it hides, which the viewer still shows; Dots pick the image, with a bright dot gliding over the active one.

The messages scroll. As you move through the images, the list scrolls behind the viewer to the message of the image shown (`scrollToTrigger`, on by default), so closing always flies back to a tile you can see. With `closeTarget="nearest"`, an image hidden behind "+1" flies back to the "+1" tile, which stays empty while such an image is shown, and turns into it on the way.

`Lightbox.createHandle()` links a lightbox to code and to triggers outside `Lightbox.Root`, like Base UI's [detached triggers](/docs/components/dialog): pass the same handle to the root, and to each `Lightbox.Trigger` placed elsewhere. Here "Shared media" opens the first image with `sharedMedia.open(value)`. Its tile is scrolled out of view, so the image fades in rather than flying from a place you can't see.

```tsx title="chat.tsx"
"use client";
import * as React from "react";
import { Lightbox } from "@logic-ui/react/lightbox";
import { getThumbnailSrc, photos, type Photo } from "./_photos";
import { CloseIcon } from "./_icons";
import { classes } from "./_classes";

/** Tailwind classes of this demo. */
const local = {
  // The media fills the screen, edge to edge; the controls float over it.
  Slide: "p-0",
  Chat: "flex w-full max-w-md flex-col overflow-hidden rounded-2xl border border-white/10 bg-neutral-950 select-none",
  Header:
    "flex items-center justify-between gap-3 border-b border-white/10 px-4 py-3",
  Channel: "m-0 text-sm font-semibold",
  Members: "m-0 text-xs text-neutral-500",
  // A fixed height that scrolls, like a chat: the viewer scrolls it behind
  // itself to the message of the image shown.
  Messages: `flex h-104 flex-col gap-4 overflow-y-auto overscroll-contain p-4 ${classes.hideScrollbar}`,
  Message: "flex gap-3",
  Avatar:
    "flex size-8 flex-none items-center justify-center rounded-full text-xs font-semibold text-white",
  Body: "flex min-w-0 flex-col gap-1.5",
  Meta: "flex items-baseline gap-2 text-sm [&_time]:text-xs [&_time]:text-neutral-500",
  Text: "m-0 text-sm text-neutral-300",
  // Up to four tiles; the last one tells how many more there are.
  Grid: "grid w-64 max-w-full grid-cols-2 gap-1",
  Tile: "aspect-square rounded-lg only:col-span-2 only:aspect-[4/3] only:rounded-xl",
  More: "absolute inset-0 flex items-center justify-center bg-black/55 text-lg font-semibold text-white",
  // Dots under the image, in a pill. The list is positioned, for the ring.
  Dots: "relative flex gap-1 rounded-full bg-neutral-900/80 p-1.5 backdrop-blur-xl",
  Dot: "flex size-5 cursor-pointer items-center justify-center rounded-full border-none bg-transparent p-0 focus-visible:outline-2 focus-visible:outline-white",
  Mark: "size-1.5 rounded-full bg-white/40",
  // Spans the active dot's button; its bright dot is drawn in the middle.
  Ring: "pointer-events-none absolute top-(--active-thumbnail-top) left-(--active-thumbnail-left) h-(--active-thumbnail-height) w-(--active-thumbnail-width) after:absolute after:inset-0 after:m-auto after:size-2 after:rounded-full after:bg-white after:content-['']",
  Caption: `m-0 max-w-sm text-center text-sm text-white drop-shadow-[0_1px_3px_rgb(0_0_0/0.8)] ${classes.enter}`,
};

type Message = {
  id: string;
  author: string;
  color: string;
  time: string;
  text: string;
  images: Photo[];
};

const ANA = { author: "Ana", color: "bg-rose-500" };
const SAM = { author: "Sam", color: "bg-sky-500" };
const LEE = { author: "Lee", color: "bg-amber-500" };

const messages: Message[] = [
  {
    id: "m1",
    ...ANA,
    time: "9:41",
    text: "Photos from the trip. The valley at sunrise was unreal.",
    images: photos.slice(0, 2),
  },
  {
    id: "m2",
    ...SAM,
    time: "9:44",
    text: "That jellyfish though.",
    images: photos.slice(5, 6),
  },
  {
    id: "m3",
    ...LEE,
    time: "9:45",
    text: "Wait, where was the waterfall?",
    images: [],
  },
  {
    id: "m4",
    ...ANA,
    time: "9:46",
    text: "Day two. The rest of the set.",
    images: [photos[2], photos[3], photos[4], photos[6], photos[7]],
  },
  {
    id: "m5",
    ...SAM,
    time: "9:52",
    text: "Saving all of these.",
    images: [],
  },
];

/** Every image of the channel, in the order of the messages. */
const allImages = messages.flatMap((message) => message.images);

/** Opens the images from a button, without a trigger of its own. */
const sharedMedia = Lightbox.createHandle();

/**
 * One lightbox for the whole channel: swipe from one message's images to the
 * next. An image without a tile, behind "+1", flies back into the "+1" tile:
 * the nearest one.
 */
export default function ExampleLightboxChat() {
  const messagesRef = React.useRef<HTMLDivElement>(null);

  // A chat opens on its latest messages.
  React.useLayoutEffect(() => {
    const list = messagesRef.current;
    if (list) list.scrollTop = list.scrollHeight;
  }, []);

  return (
    <Lightbox.Root handle={sharedMedia} items={allImages} closeTarget="nearest">
      <section className={local.Chat}>
        <header className={local.Header}>
          <div>
            <h3 className={local.Channel}># weekend-trip</h3>
            <p className={local.Members}>3 members</p>
          </div>
          {/* Opens on the first image, scrolled out of view above: it fades in. */}
          <button
            type="button"
            className={classes.button}
            onClick={() => sharedMedia.open(allImages[0].id)}
          >
            Shared media · {allImages.length}
          </button>
        </header>

        <div ref={messagesRef} className={local.Messages}>
          {messages.map((message) => {
            const shown = message.images.slice(0, 4);
            const hidden = message.images.length - shown.length;
            return (
              <article key={message.id} className={local.Message}>
                <span className={`${local.Avatar} ${message.color}`}>
                  {message.author[0]}
                </span>
                <div className={local.Body}>
                  <div className={local.Meta}>
                    <strong>{message.author}</strong>
                    <time>{message.time}</time>
                  </div>
                  <p className={local.Text}>{message.text}</p>
                  {shown.length > 0 && (
                    <div className={local.Grid}>
                      {shown.map((photo, index) => {
                        const counts = hidden > 0 && index === shown.length - 1;
                        return (
                          <Lightbox.Trigger
                            key={photo.id}
                            value={photo.id}
                            // The count fades with the flights.
                            crossfade={counts}
                            aria-label={
                              counts
                                ? `Open ${photo.alt}, and ${hidden} more`
                                : `Open ${photo.alt}`
                            }
                            className={`${classes.trigger} ${local.Tile}`}
                          >
                            <img
                              loading="lazy"
                              src={getThumbnailSrc(photo)}
                              alt=""
                              className={classes.triggerImage}
                            />
                            {counts && (
                              <span className={local.More} aria-hidden>
                                +{hidden}
                              </span>
                            )}
                          </Lightbox.Trigger>
                        );
                      })}
                    </div>
                  )}
                </div>
              </article>
            );
          })}
        </div>
      </section>

      <Lightbox.Portal>
        <Lightbox.Backdrop className={classes.backdrop} />
        <Lightbox.Popup className={classes.popup}>
          <Lightbox.Viewport className={classes.viewport}>
            {(photo: Photo) => (
              <Lightbox.Slide className={local.Slide}>
                <Lightbox.Media width={photo.width} height={photo.height}>
                  <img src={photo.src} alt={photo.alt} />
                </Lightbox.Media>
              </Lightbox.Slide>
            )}
          </Lightbox.Viewport>
          <header
            className={`${classes.topBar} ${classes.shadeTop} ${classes.controlTop}`}
          >
            <Lightbox.Counter className={classes.counter} />
            <Lightbox.Close className={classes.iconButton}>
              <CloseIcon />
            </Lightbox.Close>
          </header>
          <footer
            className={`${classes.bottomBar} ${classes.shadeBottom} ${classes.controlBottom}`}
          >
            <Lightbox.Description className={local.Caption}>
              {(photo: Photo) => photo.alt}
            </Lightbox.Description>
            <Lightbox.ThumbnailList className={local.Dots}>
              {(photo: Photo) => (
                <Lightbox.Thumbnail
                  aria-label={photo.alt}
                  className={local.Dot}
                >
                  <span className={local.Mark} />
                </Lightbox.Thumbnail>
              )}
              {/* A bright dot that glides over the active one. */}
              <Lightbox.ThumbnailIndicator className={local.Ring} />
            </Lightbox.ThumbnailList>
          </footer>
        </Lightbox.Popup>
      </Lightbox.Portal>
    </Lightbox.Root>
  );
}
```

### Where the image lands

When the viewer closes, the image flies back to a trigger on the page, and focus returns to it. `closeTarget` on `Lightbox.Root` picks which:

- `"active"` (the default): the trigger of the item shown. Without one on the page, the image fades out where it is.
- `"opener"`: the trigger that opened the viewer, whichever item is shown. For a cover that opens a whole album, like the ones below.
- `"nearest"`: the trigger of the item shown, or else the trigger of the nearest item that has one. For a grid that shows a few items and a "+5" tile, like the gallery at the top of the page.

A trigger that shows something other than its media takes `crossfade`: an avatar for a story, or a tile with a "+5" count or a play badge, which then fade out as the image leaves and back in as it lands. With it, the image grows out of its look as the viewer opens, and turns back into it on the way home, like the stories below.

The trigger the image will fly back to has `data-popup-open` the whole time the viewer is open, following the item shown: hide it (`data-popup-open:invisible`) and its slot waits empty, for the drop after a drag too. When that trigger shows another image (the cover, the "+5" tile), a copy of it flies along with the image and fades in over it, so the image turns into the trigger on the way, and lands looking exactly like it. A trigger that can't be seen, scrolled out of the window or out of a list that clips it, gets the image faded out instead; `scrollToTrigger` scrolls the page to it beforehand, as in the chat above.

```tsx title="album.tsx"
"use client";
import * as React from "react";
import { Lightbox } from "@logic-ui/react/lightbox";
import { getThumbnailSrc, photos, type Photo } from "./_photos";
import { ChevronIcon, CloseIcon } from "./_icons";
import { classes } from "./_classes";

/** Tailwind classes of this demo. */
const local = {
  Shelf: "flex flex-wrap justify-center gap-10 select-none",
  Album: "group/album flex w-40 flex-col gap-3",
  // The cover on a stack of two more photos, which fan out on hover.
  Stack: "relative size-40",
  Behind:
    "pointer-events-none absolute inset-0 size-full rounded-2xl object-cover shadow-lg ring-1 ring-white/10 transition-transform duration-500 ease-[cubic-bezier(0.32,0.72,0,1)]",
  BehindLeft:
    "-translate-x-2.5 -rotate-8 opacity-55 group-hover/album:-translate-x-5 group-hover/album:-rotate-12",
  BehindRight:
    "translate-x-2.5 rotate-6 opacity-80 group-hover/album:translate-x-5 group-hover/album:rotate-10",
  // The cover stays on the page while the viewer shows another photo: the
  // photo dissolves into it on close.
  Cover: "size-full rounded-2xl shadow-2xl ring-1 ring-white/15",
  Title: "m-0 text-sm font-semibold",
  Count: "m-0 text-xs text-neutral-500",
  Nav: "flex w-full max-w-md items-center justify-between gap-4",
  Caption: `m-0 min-w-0 flex-1 truncate text-center text-sm text-neutral-300 ${classes.enter}`,
};

type Album = { id: string; title: string; photos: Photo[] };

const albums: Album[] = [
  {
    id: "iceland",
    title: "Iceland",
    photos: [photos[0], photos[2], photos[7], photos[3]],
  },
  {
    id: "city",
    title: "Faces and places",
    photos: [photos[1], photos[6], photos[5], photos[4]],
  },
];

/** One album: its cover opens all of its photos. */
function AlbumCover(props: { album: Album }) {
  const { album } = props;
  const [cover, second, third] = album.photos;
  return (
    // Whichever photo is shown, it flies back into the cover.
    <Lightbox.Root items={album.photos} closeTarget="opener">
      <div className={local.Album}>
        <div className={local.Stack}>
          <img
            loading="lazy"
            src={getThumbnailSrc(third)}
            alt=""
            className={`${local.Behind} ${local.BehindLeft}`}
          />
          <img
            loading="lazy"
            src={getThumbnailSrc(second)}
            alt=""
            className={`${local.Behind} ${local.BehindRight}`}
          />
          <Lightbox.Trigger
            value={cover.id}
            aria-label={`Open the album ${album.title}, ${album.photos.length} photos`}
            className={`${classes.trigger} ${local.Cover}`}
          >
            <img
              loading="lazy"
              src={getThumbnailSrc(cover)}
              alt=""
              className={classes.triggerImage}
            />
          </Lightbox.Trigger>
        </div>
        <div>
          <p className={local.Title}>{album.title}</p>
          <p className={local.Count}>{album.photos.length} photos</p>
        </div>
      </div>

      <Lightbox.Portal>
        <Lightbox.Backdrop className={classes.backdrop} />
        <Lightbox.Popup className={classes.popup}>
          <Lightbox.Viewport className={classes.viewport}>
            {(photo: Photo) => (
              <Lightbox.Slide className={classes.slide}>
                <Lightbox.Media width={photo.width} height={photo.height}>
                  <img src={photo.src} alt={photo.alt} />
                </Lightbox.Media>
              </Lightbox.Slide>
            )}
          </Lightbox.Viewport>
          <header className={`${classes.topBar} ${classes.controlTop}`}>
            <Lightbox.Counter className={classes.counter} />
            <Lightbox.Title className="m-0 text-sm font-semibold">
              {album.title}
            </Lightbox.Title>
            <Lightbox.Close className={classes.iconButton}>
              <CloseIcon />
            </Lightbox.Close>
          </header>
          <footer className={`${classes.bottomBar} ${classes.controlBottom}`}>
            <div className={local.Nav}>
              <Lightbox.Previous className={classes.iconButton}>
                <ChevronIcon direction="left" />
              </Lightbox.Previous>
              <Lightbox.Description className={local.Caption}>
                {(photo: Photo) => photo.alt}
              </Lightbox.Description>
              <Lightbox.Next className={classes.iconButton}>
                <ChevronIcon direction="right" />
              </Lightbox.Next>
            </div>
          </footer>
        </Lightbox.Popup>
      </Lightbox.Portal>
    </Lightbox.Root>
  );
}

/** A shelf of albums, like a photo app's. */
export default function ExampleLightboxAlbum() {
  return (
    <div className={local.Shelf}>
      {albums.map((album) => (
        <AlbumCover key={album.id} album={album} />
      ))}
    </div>
  );
}
```

```tsx title="An album cover"
<Lightbox.Root items={album.photos} closeTarget="opener">
  <Lightbox.Trigger value={album.photos[0].id}>
    <img src={album.cover} alt="" />
  </Lightbox.Trigger>
  …
</Lightbox.Root>
```

### Instagram-style stories

The people stay in one horizontally scrollable row on every screen size. Their rings change color and opacity on hover without lifting the avatars. The demo uses `select-none` to avoid accidental selection, except for the reply field, which keeps `select-text`.

Each ring is a person, and the items of the lightbox are the people: swipe, or drag past the edge of the card, to go from one person to the next. A person's own stories play inside their slide, like Instagram:

- **They play by themselves.** One bar per story fills for five seconds, or for the length of a video, then the next story shows, then the next person. After the last one, the viewer closes, and the ring turns grey.
- **Tap** the left third of the card to go back, anywhere else to go on; ← → do the same. **Hold** to pause; the controls fade while you hold. The pause button and typing a reply pause too, as WCAG asks of content that moves on by itself.
- **Controlled.** The stories move on to the next person by changing `value`, and the viewer slides there as it does for Next; after the last person, `open` turns `false`. `onOpenChangeComplete` starts the bars once the card has landed. The viewer loads the story the person is on before the card flies out of their ring.
- **Keys of your own.** The popup's `onKeyDown` runs before its shortcuts: it takes ← → for the stories and prevents the default, so the library doesn't also change the person.

The rings take `crossfade`, since they show a face rather than the story: the card grows out of the face as it opens, and turns back into it as it flies home.

Every control sits on the story card, inside `Lightbox.Media`: the bars, the author, pause, `Lightbox.Close`, the story's text in a `Lightbox.Caption`, and the reply field. So they slide with the person during a swipe, follow the pull to dismiss, and fly back into the ring with the card. Controls inside a slide get their clicks: a press that starts on a button, a link or a field isn't a gesture. `maxScale={1}` keeps the stories at their resting scale.

Every story is the same shape: `Lightbox.Media` takes `fill`, so the card fills its slide whatever the shape of the photo or video, and `object-cover` on the media covers it. The viewport sets the size in CSS: 9:16 on a large screen, the whole screen on a phone.

The stories turn like the faces of a cube, in CSS only. Each `Lightbox.Slide` has `--lightbox-slide-progress`, its distance from the centre in slides (`0` shown, `-1` one to the left, `1` one to the right), on every frame of a swipe, its settle and Next. The library places slides with `translate`, so their `transform` is free: here each person turns by the progress times 90° around the edge they share with the next one, with a `perspective` on `Lightbox.Viewport`. The same variable makes a fade (`opacity`), a stack, or a coverflow.

```tsx title="Cube transition"
<Lightbox.Viewport className="[perspective:1400px] gap-x-0">
  {(story) => (
    <Lightbox.Slide className="[transform-origin:clamp(0%,calc(50%_-_var(--lightbox-slide-progress)*10000%),100%)_50%] [transform:rotateY(calc(var(--lightbox-slide-progress)*90deg))] [backface-visibility:hidden]">
      …
    </Lightbox.Slide>
  )}
</Lightbox.Viewport>
```

```tsx title="stories.tsx"
"use client";
import * as React from "react";
import { Lightbox, type LightboxItemValue } from "@logic-ui/react/lightbox";
import { useStableCallback } from "@logic-ui/react/utils";
import { cn } from "cn";
import { videos } from "./_photos";
import {
  ChevronIcon,
  CloseIcon,
  HeartIcon,
  PauseIcon,
  PlayIcon,
  SendIcon,
} from "./_icons";
import { classes } from "./_classes";

/** One photo or video in a person's stories. */
type Frame = {
  id: string;
  src: string;
  alt: string;
  time: string;
  text?: string;
  video?: boolean;
};

/** A person in the row: the lightbox item. Their stories play in their slide. */
type Person = {
  id: string;
  name: string;
  avatar: string;
  frames: Frame[];
};

const story = (id: number) => `https://picsum.photos/id/${id}/900/1600`;
const face = (id: number) => `https://i.pravatar.cc/150?img=${id}`;

const people: Person[] = [
  {
    id: "maya",
    name: "Maya",
    avatar: face(32),
    frames: [
      {
        id: "maya-1",
        src: story(1011),
        alt: "Paddling a canoe across a still lake",
        time: "5h",
        text: "Morning paddle before anyone woke up",
      },
      {
        id: "maya-2",
        src: story(1039),
        alt: "A waterfall over a mossy cliff",
        time: "4h",
      },
      {
        id: "maya-3",
        src: story(1036),
        alt: "Tents in the snow below the mountains",
        time: "2h",
        text: "Camp three. Freezing, but look at it",
      },
    ],
  },
  {
    id: "noah",
    name: "Noah",
    avatar: face(12),
    frames: [
      {
        id: "noah-1",
        src: story(1031),
        alt: "A glass tower rising into the fog",
        time: "8h",
        text: "Looking up more often",
      },
      {
        id: "noah-2",
        src: story(1033),
        alt: "Escalators in a station",
        time: "7h",
      },
      {
        id: "noah-3",
        src: story(1047),
        alt: "A narrow brick alley",
        time: "6h",
      },
      {
        id: "noah-4",
        src: story(1078),
        alt: "Lit windows of an office block at night",
        time: "1h",
        text: "Everyone's still at work",
      },
    ],
  },
  {
    id: "sofia",
    name: "Sofia",
    avatar: face(24),
    frames: [
      {
        id: "sofia-1",
        src: story(1060),
        alt: "Coffee brewing in a pour-over",
        time: "3h",
        text: "Pour-over o'clock",
      },
      {
        id: "sofia-2",
        src: story(1080),
        alt: "A pile of strawberries",
        time: "2h",
        text: "Market haul",
      },
      {
        id: "sofia-3",
        src: videos[0].src,
        alt: "A flower opening",
        time: "45m",
        video: true,
      },
    ],
  },
  {
    id: "leo",
    name: "Leo",
    avatar: face(13),
    frames: [
      {
        id: "leo-1",
        src: story(1025),
        alt: "A pug wrapped in a blanket outdoors",
        time: "6h",
        text: "He refuses to walk in the cold",
      },
      {
        id: "leo-2",
        src: story(1062),
        alt: "The pug in a blanket on the bed",
        time: "30m",
        text: "Monday mood",
      },
    ],
  },
  {
    id: "ava",
    name: "Ava",
    avatar: face(16),
    frames: [
      {
        id: "ava-1",
        src: story(1074),
        alt: "A lioness looking into the camera",
        time: "9h",
        text: "She stared for a full minute",
      },
      {
        id: "ava-2",
        src: story(1003),
        alt: "A fawn in a sunlit forest",
        time: "7h",
      },
      {
        id: "ava-3",
        src: story(1024),
        alt: "A vulture spreading its wings",
        time: "3h",
      },
    ],
  },
  {
    id: "kai",
    name: "Kai",
    avatar: face(14),
    frames: [
      {
        id: "kai-1",
        src: story(1072),
        alt: "An old truck in a field",
        time: "12h",
        text: "Found her behind a barn",
      },
      {
        id: "kai-2",
        src: story(1071),
        alt: "The chrome grille of a vintage car",
        time: "10h",
      },
      {
        id: "kai-3",
        src: story(1057),
        alt: "A coast road above the sea",
        time: "4h",
        text: "Road trip, day one",
      },
    ],
  },
];

/** How long a photo stays on screen; a video plays to its end (ms). */
const PHOTO_DURATION = 5000;
/** A press that moves less than this is a tap (px). */
const TAP_SLOP = 8;
/** A press held longer than this pauses instead of moving on (ms). */
const HOLD_DELAY = 250;

/** Whether a press started on a control of the card, which handles it. */
function isOnControl(target: EventTarget): boolean {
  return Boolean((target as Partial<Element>).closest?.("button, a, input"));
}

/**
 * The controls ride on the story card: they fade as the viewer opens or
 * closes and while you hold the story, but follow the pull to dismiss and fly
 * back into the ring with the card.
 */
const onCard =
  "transition-opacity duration-250 ease-out group-data-holding:opacity-0 group-data-holding:delay-200 in-data-starting-style:opacity-0 in-data-ending-style:opacity-0 in-data-ending-style:duration-150 motion-reduce:transition-none";

/** Tailwind classes of this demo. */
const local = {
  Feed: "w-full min-w-0 max-w-xl cursor-default rounded-3xl border border-white/10 bg-linear-to-b from-neutral-900 to-neutral-950 p-4 shadow-xl shadow-black/10 select-none sm:p-5",
  FeedHeader:
    "mb-3 flex items-start justify-between gap-3 border-b border-white/10 pb-4 sm:gap-4",
  FeedTitle: "m-0 text-base font-semibold tracking-tight text-white",
  FeedHint: "m-0 mt-1 text-xs leading-relaxed text-neutral-400",
  FeedCount:
    "shrink-0 rounded-full border border-rose-400/20 bg-rose-400/10 px-2.5 py-1 text-[0.6875rem] font-medium text-rose-200 tabular-nums",
  List: cn(
    classes.hideScrollbar,
    "-mx-2 my-0 flex min-w-0 list-none flex-nowrap items-start gap-3 overflow-x-auto overscroll-x-contain scroll-px-2 p-2 sm:gap-4",
  ),
  Item: "group/story flex w-16 flex-none flex-col items-center gap-2.5 sm:w-18",
  // The gradient ring of someone with new stories; grey once watched.
  Ring: "rounded-full bg-linear-to-tr from-amber-400 via-rose-500 to-fuchsia-500 p-0.5 transition-opacity duration-200 ease-out group-hover/story:opacity-85 group-focus-within/story:opacity-85 data-seen:from-neutral-600 data-seen:via-neutral-600 data-seen:to-neutral-600 motion-reduce:transition-none",
  Trigger:
    "group block size-14 cursor-pointer overflow-hidden rounded-full border-[3px] border-neutral-950 bg-neutral-900 p-0 data-flying:invisible data-popup-open:invisible focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-white sm:size-16",
  Name: "w-full truncate text-center text-xs font-medium text-neutral-300 transition-colors group-hover/story:text-white group-focus-within/story:text-white motion-reduce:transition-none",
  Popup:
    "fixed inset-0 cursor-default text-white outline-none select-none [--story-gutter:max(1rem,env(safe-area-inset-top),env(safe-area-inset-bottom))] [--story-height:min(46rem,calc(100dvh-2*var(--story-gutter)))] [--story-inline-gutter:max(0.75rem,env(safe-area-inset-left),env(safe-area-inset-right))] sm:[--story-gutter:max(2.5rem,env(safe-area-inset-top),env(safe-area-inset-bottom))]",
  // Exactly the size of a story, so the people meet edge to edge like the
  // faces of a cube; the whole screen on a phone. The perspective reaches the
  // slides turned in 3D, and it doesn't clip: neighbours wait edge-on, and
  // the card leaves it when dragged.
  Viewport:
    "absolute inset-0 cursor-default gap-x-0 select-none [perspective:1400px] sm:inset-auto sm:top-1/2 sm:left-1/2 sm:aspect-9/16 sm:w-[min(calc(var(--story-height)*9/16),calc(100vw-2*var(--story-inline-gutter)))] sm:-translate-1/2",
  /**
   * The cube: each person turns away around the edge they share with the
   * next one, by `--lightbox-slide-progress` times 90°. The pivot is on the
   * right edge while a slide leaves to the left, on the left edge while it
   * comes in from the right, and the face turning away darkens.
   */
  Slide:
    "cursor-default select-none [transform-origin:clamp(0%,calc(50%_-_var(--lightbox-slide-progress)*10000%),100%)_50%] [transform:rotateY(calc(var(--lightbox-slide-progress)*90deg))] [backface-visibility:hidden] [filter:brightness(calc(1_-_min(1,max(var(--lightbox-slide-progress),-1*var(--lightbox-slide-progress)))*0.6))] motion-reduce:[transform:none]",
  // The card: the story with its controls on it, filling the slide whatever
  // the shape of the photo or video, which covers it. It clips them to its
  // corners, square on a phone, where it fills the screen.
  Media:
    "group @container/story overflow-hidden bg-neutral-900 sm:rounded-3xl [&>img]:object-cover [&>video]:object-cover",
  Header: cn(
    classes.shadeTop,
    onCard,
    "absolute inset-x-0 top-0 flex flex-col gap-2.5 px-3 pt-[max(0.75rem,env(safe-area-inset-top))] pb-12 @xs/story:px-4 sm:pt-3",
  ),
  Segments: "flex gap-1",
  // One bar per story of the person: full once watched, filling while shown.
  Segment:
    "relative h-0.5 min-w-0 flex-1 overflow-hidden rounded-full bg-white/30",
  Fill: "absolute inset-0 origin-left scale-x-0 rounded-full bg-white data-watched:scale-x-100",
  Profile: "flex items-center justify-between gap-2",
  Author: "m-0 flex min-w-0 items-center gap-2 text-sm font-semibold",
  Avatar:
    "size-8 shrink-0 rounded-full object-cover ring-2 ring-white/25 @xs/story:size-9",
  Time: "ms-1.5 font-normal text-white/65",
  Buttons: "flex items-center gap-1",
  Button: cn(
    classes.iconButton,
    "size-10 shrink-0 bg-transparent backdrop-blur-none transition-[background-color,scale] hover:not-data-disabled:bg-white/15 active:scale-95 motion-reduce:scale-100 motion-reduce:transition-none",
  ),
  // Big tap targets aren't enough with a mouse: arrows on the card's edges.
  Step: cn(
    classes.iconButton,
    onCard,
    "absolute top-1/2 hidden size-9 -translate-y-1/2 bg-black/35 opacity-0 group-hover:opacity-100 pointer-fine:flex",
  ),
  Footer: cn(
    classes.shadeBottom,
    onCard,
    "absolute inset-x-0 bottom-0 flex flex-col items-start gap-4 px-3 pt-16 pb-[max(0.75rem,env(safe-area-inset-bottom))] @xs/story:px-4 sm:pb-3 sm:@xs/story:pb-4",
  ),
  Text: `m-0 max-w-[85%] rounded-xl bg-black/45 px-3 py-2 text-[0.9375rem] leading-snug font-semibold tracking-tight text-pretty backdrop-blur-sm @xs/story:text-lg ${classes.enter}`,
  Reply: "flex w-full items-center gap-1",
  Input:
    "h-11 min-w-0 flex-1 cursor-text rounded-full border border-white/40 bg-transparent px-4 text-sm text-white select-text placeholder:text-white/70 focus:border-white focus:outline-none",
  Like: "[&[aria-pressed=true]]:text-rose-500 [&[aria-pressed=true]_svg]:scale-110 [&[aria-pressed=true]_svg]:fill-current [&_svg]:transition-transform [&_svg]:duration-200",
  Hint: cn(
    classes.controlBottom,
    "pointer-events-none absolute inset-x-0 bottom-[max(0.75rem,env(safe-area-inset-bottom))] hidden flex-wrap justify-center gap-x-4 gap-y-1 px-4 text-center text-[0.6875rem] text-white/40 sm:flex",
  ),
  Error:
    "absolute inset-0 m-auto hidden h-fit px-6 text-center text-sm text-white/70 group-data-error:block",
};

type StoryCardProps = {
  person: Person;
  frameIndex: number;
  /** Whether the slide is the one shown. */
  active: boolean;
  /** Whether the shown story moves on by itself (not paused, held or typed in). */
  playing: boolean;
  /** Changes to play the same story again from its start. */
  run: number;
  holding: boolean;
  paused: boolean;
  liked: boolean;
  onStep: (delta: 1 | -1) => void;
  onPausedChange: (paused: boolean) => void;
  onLikedChange: (liked: boolean) => void;
  onTypingChange: (typing: boolean) => void;
};

/** A person's slide: their current story, its progress, and the controls. */
function StoryCard(props: StoryCardProps) {
  const {
    person,
    frameIndex,
    active,
    playing,
    run,
    holding,
    paused,
    liked,
    onStep,
    onPausedChange,
    onLikedChange,
    onTypingChange,
  } = props;
  const frame = person.frames[frameIndex];
  const fillRef = React.useRef<HTMLSpanElement>(null);
  const videoRef = React.useRef<HTMLVideoElement>(null);
  const animationRef = React.useRef<Animation | null>(null);
  // Stable: a new `onStep` each render mustn't restart the bar.
  const stepForward = useStableCallback(() => onStep(1));

  // How long the story lasts, once its media has loaded.
  const [timing, setTiming] = React.useState<{
    frameId: string;
    duration: number;
  } | null>(null);
  const duration = timing?.frameId === frame.id ? timing.duration : null;

  // The bar of the story shown fills over its duration, then moves on. It
  // keeps its place while paused or while another person is shown.
  React.useEffect(() => {
    const fill = fillRef.current;
    if (!fill || duration === null || typeof fill.animate !== "function")
      return undefined;
    const animation = fill.animate(
      // `scale`, the property the fill's `scale-x-0` sets.
      [{ scale: "0 1" }, { scale: "1 1" }],
      { duration, fill: "forwards" },
    );
    animation.pause();
    animation.onfinish = stepForward;
    animationRef.current = animation;
    return () => {
      animation.cancel();
      animationRef.current = null;
    };
  }, [frame.id, duration, run, stepForward]);

  const running = active && playing;
  React.useEffect(() => {
    const animation = animationRef.current;
    if (running) animation?.play();
    else animation?.pause();
    const video = videoRef.current;
    if (!video) return;
    if (running) video.play().catch(() => {});
    else video.pause();
  }, [running, frame.id, duration, run]);

  // Playing the same story again starts its video over too.
  React.useEffect(() => {
    if (videoRef.current) videoRef.current.currentTime = 0;
  }, [run]);

  const done = () => setTiming({ frameId: frame.id, duration: PHOTO_DURATION });

  return (
    <Lightbox.Media
      fill
      data-holding={holding || undefined}
      className={local.Media}
    >
      {frame.video ? (
        <video
          key={frame.id}
          ref={videoRef}
          src={frame.src}
          aria-label={frame.alt}
          muted
          playsInline
          preload="auto"
          onLoadedMetadata={(event) =>
            setTiming({
              frameId: frame.id,
              duration: event.currentTarget.duration * 1000,
            })
          }
          onError={done}
        />
      ) : (
        <img
          key={frame.id}
          src={frame.src}
          alt={frame.alt}
          onLoad={done}
          onError={done}
          className="group-data-error:invisible"
        />
      )}
      <span
        className={cn(classes.spinner, "motion-reduce:animate-none")}
        aria-hidden
      />
      <p role="alert" className={local.Error}>
        This story couldn’t be loaded.
      </p>

      <header className={local.Header}>
        <div className={local.Segments} aria-hidden>
          {person.frames.map((other, index) => (
            <span key={other.id} className={local.Segment}>
              <span
                ref={index === frameIndex ? fillRef : undefined}
                data-watched={index < frameIndex || undefined}
                className={local.Fill}
              />
            </span>
          ))}
        </div>
        <div className={local.Profile}>
          <p className={local.Author}>
            <img src={person.avatar} alt="" className={local.Avatar} />
            <span>
              {person.name}
              <span className={local.Time}>{frame.time}</span>
            </span>
          </p>
          <div className={local.Buttons}>
            {/* Stories move on by themselves, so they can be paused (WCAG 2.2.2). */}
            <button
              type="button"
              aria-label={paused ? "Play stories" : "Pause stories"}
              className={local.Button}
              onClick={() => onPausedChange(!paused)}
            >
              {paused ? <PlayIcon /> : <PauseIcon />}
            </button>
            <Lightbox.Close aria-label="Close stories" className={local.Button}>
              <CloseIcon />
            </Lightbox.Close>
          </div>
        </div>
      </header>

      <button
        type="button"
        aria-label="Previous story"
        className={cn(local.Step, "start-2")}
        onClick={() => onStep(-1)}
      >
        <ChevronIcon direction="left" />
      </button>
      <button
        type="button"
        aria-label="Next story"
        className={cn(local.Step, "end-2")}
        onClick={() => onStep(1)}
      >
        <ChevronIcon direction="right" />
      </button>

      <footer className={local.Footer}>
        {frame.text && (
          <Lightbox.Caption className={local.Text}>
            {frame.text}
          </Lightbox.Caption>
        )}
        <form
          className={local.Reply}
          onSubmit={(event) => {
            event.preventDefault();
            event.currentTarget.reset();
          }}
        >
          {/* Typing pauses the story. */}
          <input
            name="reply"
            aria-label={`Reply to ${person.name}`}
            placeholder={`Reply to ${person.name}…`}
            autoComplete="off"
            className={local.Input}
            onFocus={() => onTypingChange(true)}
            onBlur={() => onTypingChange(false)}
          />
          <button
            type="button"
            aria-label="Like"
            aria-pressed={liked}
            className={cn(local.Button, local.Like)}
            onClick={() => onLikedChange(!liked)}
          >
            <HeartIcon className="size-5" />
          </button>
          <button type="submit" aria-label="Send" className={local.Button}>
            <SendIcon className="size-5" />
          </button>
        </form>
      </footer>
    </Lightbox.Media>
  );
}

/**
 * Instagram-style stories. Each ring is a person, and their stories play in
 * their slide: the bars fill and move on by themselves, a tap on the left or
 * right of the card (or ← →) steps through them, holding pauses, and the last
 * story moves on to the next person. Swipe to change person.
 */
export default function ExampleLightboxStories(): React.ReactElement {
  // Controlled, so the stories can move on to the next person by themselves,
  // and close after the last one.
  const [open, setOpen] = React.useState(false);
  const [value, setValue] = React.useState<LightboxItemValue>(people[0].id);
  // The person shown, at once: a press reports them just before it opens.
  const valueRef = React.useRef(value);
  // The story each person is on, and who has been watched to the end.
  const [positions, setPositions] = React.useState<Record<string, number>>({});
  const [seen, setSeen] = React.useState<ReadonlySet<string>>(() => new Set());
  const [liked, setLiked] = React.useState<ReadonlySet<string>>(
    () => new Set(),
  );
  const [run, setRun] = React.useState(0);
  // The card has landed: until then, nothing plays.
  const [landed, setLanded] = React.useState(false);
  const [paused, setPaused] = React.useState(false);
  const [holding, setHolding] = React.useState(false);
  const [typing, setTyping] = React.useState(false);
  const pressRef = React.useRef<{ x: number; y: number; time: number } | null>(
    null,
  );

  /** Someone watched to the end starts over; anyone else resumes. */
  const getStart = (person: Person) => {
    const position = positions[person.id] ?? 0;
    return seen.has(person.id) && position === person.frames.length - 1
      ? 0
      : position;
  };

  const showPerson = (next: LightboxItemValue) => {
    const person = people.find((other) => other.id === next);
    if (person)
      setPositions((current) => ({
        ...current,
        [person.id]: getStart(person),
      }));
    valueRef.current = next;
    setValue(next);
  };

  /** Steps through the stories of the person shown, then on to the next person. */
  const step = (delta: 1 | -1) => {
    const index = people.findIndex((person) => person.id === value);
    const person = people[index];
    if (!person) return;
    const next = (positions[person.id] ?? 0) + delta;
    if (next >= person.frames.length) {
      setSeen((current) => new Set(current).add(person.id));
      const nextPerson = people[index + 1];
      if (nextPerson) showPerson(nextPerson.id);
      else setOpen(false);
    } else if (next < 0) {
      const previous = people[index - 1];
      if (previous) showPerson(previous.id);
      else setRun((current) => current + 1);
    } else {
      setPositions((current) => ({ ...current, [person.id]: next }));
    }
  };

  // Holding the story pauses it until the press ends, wherever it ends.
  React.useEffect(() => {
    if (!holding) return undefined;
    const release = () => setHolding(false);
    window.addEventListener("pointerup", release);
    window.addEventListener("pointercancel", release);
    return () => {
      window.removeEventListener("pointerup", release);
      window.removeEventListener("pointercancel", release);
    };
  }, [holding]);

  const playing = landed && !paused && !holding && !typing;
  const newCount = people.filter((person) => !seen.has(person.id)).length;

  return (
    <Lightbox.Root
      items={people}
      open={open}
      onOpenChange={(nextOpen) => {
        // Each person picks up where they left off, or starts again once seen.
        if (nextOpen) showPerson(valueRef.current);
        setOpen(nextOpen);
      }}
      value={value}
      onValueChange={showPerson}
      onOpenChangeComplete={(isOpen) => {
        setLanded(isOpen);
        if (!isOpen) setPaused(false);
      }}
      maxScale={1}
    >
      <section aria-label="Stories" className={local.Feed}>
        <header className={local.FeedHeader}>
          <div className="min-w-0">
            <h3 className={local.FeedTitle}>Stories</h3>
            <p className={local.FeedHint}>From the people you follow.</p>
          </div>
          <span className={local.FeedCount}>{newCount} new</span>
        </header>
        <ul aria-label="People with stories" className={local.List}>
          {people.map((person) => (
            <li key={person.id} className={local.Item}>
              <div
                data-seen={seen.has(person.id) || undefined}
                className={local.Ring}
              >
                {/* The ring shows a face, not the story: the card grows out of
                    it and turns back into it on the way home. */}
                <Lightbox.Trigger
                  value={person.id}
                  crossfade
                  aria-label={`Open ${person.name}'s stories`}
                  className={local.Trigger}
                >
                  <img
                    loading="lazy"
                    src={person.avatar}
                    alt=""
                    className={classes.triggerImage}
                  />
                </Lightbox.Trigger>
              </div>
              <span className={local.Name}>{person.name}</span>
            </li>
          ))}
        </ul>
      </section>

      <Lightbox.Portal>
        <Lightbox.Backdrop
          className={cn(
            classes.backdrop,
            "cursor-default select-none motion-reduce:transition-none",
          )}
        />
        <Lightbox.Popup
          className={local.Popup}
          // ← → step through the stories, not the people: the library's
          // shortcut runs only when this handler doesn't prevent it.
          onKeyDown={(event) => {
            if ((event.target as Partial<Element>).closest?.("input")) return;
            if (event.key === "ArrowRight" || event.key === "ArrowLeft") {
              event.preventDefault();
              step(event.key === "ArrowRight" ? 1 : -1);
            } else if (
              event.key === " " &&
              event.target === event.currentTarget
            ) {
              event.preventDefault();
              setPaused((current) => !current);
            }
          }}
        >
          <Lightbox.Title className="sr-only">Stories</Lightbox.Title>
          <Lightbox.Viewport
            aria-label="Stories"
            className={local.Viewport}
            onPointerDown={(event) => {
              if (isOnControl(event.target)) return;
              pressRef.current = {
                x: event.clientX,
                y: event.clientY,
                time: event.timeStamp,
              };
              setHolding(true);
            }}
            // A quick tap on the left third goes back, anywhere else forward.
            // A drag (a swipe, a pull to dismiss) or a hold isn't a tap.
            onClick={(event) => {
              const press = pressRef.current;
              pressRef.current = null;
              if (!press || isOnControl(event.target)) return;
              const moved = Math.hypot(
                event.clientX - press.x,
                event.clientY - press.y,
              );
              if (moved > TAP_SLOP || event.timeStamp - press.time > HOLD_DELAY)
                return;
              const rect = event.currentTarget.getBoundingClientRect();
              step(event.clientX < rect.left + rect.width / 3 ? -1 : 1);
            }}
          >
            {(person: Person) => {
              const frameIndex = positions[person.id] ?? 0;
              const frame = person.frames[frameIndex];
              return (
                <Lightbox.Slide className={local.Slide}>
                  {/* Everything on the card drags with it and flies back into the ring. */}
                  <StoryCard
                    person={person}
                    frameIndex={frameIndex}
                    active={open && person.id === value}
                    playing={playing}
                    run={run}
                    holding={holding && open && person.id === value}
                    paused={paused}
                    liked={liked.has(frame.id)}
                    onStep={step}
                    onPausedChange={setPaused}
                    onTypingChange={setTyping}
                    onLikedChange={(isLiked) =>
                      setLiked((current) => {
                        const next = new Set(current);
                        if (isLiked) next.add(frame.id);
                        else next.delete(frame.id);
                        return next;
                      })
                    }
                  />
                </Lightbox.Slide>
              );
            }}
          </Lightbox.Viewport>
          <div className={local.Hint} aria-hidden>
            <span>Tap or ← → for the next story</span>
            <span>Hold to pause</span>
            <span>Swipe for the next person</span>
            <span>Pull down to dismiss</span>
          </div>
        </Lightbox.Popup>
      </Lightbox.Portal>
    </Lightbox.Root>
  );
}
```

### 3D coverflow

The same variable makes a coverflow. `neighbors={3}` on `Lightbox.Viewport` renders three covers on each side, instead of one. The covers sit closer together than the viewport is wide, so `--lightbox-swipe-distance` on the viewport sets how far the finger travels for one cover, as any CSS length (`[--lightbox-swipe-distance:calc(var(--cover-size)*2.2)]`), and the covers keep up with the finger instead of lagging behind it. Slides placed side by side don't need it, even when the media is smaller than the viewport: the media moves with its slide. While you hold a cover, the viewport has `data-swiping`; raising it (`data-swiping:z-10`) draws the cover above its reflection and the controls.

The structure separates four jobs:

- `Lightbox.Slide` places the cover in the fan and sorts it by depth. Placement stays outside perspective, so depth does not stretch the spacing.
- An inner pose turns the cover in its own `perspective()`, flattened into the slide. A smoothstep curve eases the turn into each wing. The slides stay parallel, so covers overlap without cutting through each other, including halfway through a swipe.
- A surface inside the pose handles the pull and the exit. While the cover shown is pulled to dismiss, the others follow it a little (`--lightbox-dismiss-movement-x` and `-y`), shrink slightly and dim with `--lightbox-dismiss-progress`. On `data-ending-style`, while the active `Lightbox.Media` flies back to its trigger, they fly there too: each cancels its own distance from the centre, moves by `--lightbox-flight-movement-x` and `-y`, and scales down to almost nothing from `--lightbox-flight-scale`, fading out in 60% of `--lightbox-flight-duration`. The ambient background, captions and controls leave on the same timing. Do not transform or fade the active surface or the whole popup: that would move or fade its return flight too.
- A decorative, masked image below the media supplies the reflection without a browser-specific property. It fades on close and zoom. It is outside `Lightbox.Media`, so it never becomes part of the flying image.

The cover size, perspective and depth scale together on smaller screens. The viewport fills the viewer; slide padding sizes and positions the resting covers. This keeps zoom and pan constrained by the viewer edges rather than the small cover box, avoiding empty strips when the enlarged image can fill that axis. While zoomed, neighboring covers and reflections fade away; keep the media's overflow visible so the enlarged image is not clipped. Put the ring and shadow on the image, not `Lightbox.Media`: zoom and pan transform only the image, so a frame on the media box would stay behind. The image inherits the media's rounded corners, including during zoom and the opening and closing flights. Reduced motion uses a flat carousel without reflections or entrance/exit movement.

```tsx title="coverflow.tsx"
"use client";
import * as React from "react";
import { cn } from "cn";
import { Lightbox } from "@logic-ui/react/lightbox";
import { Toolbar } from "@logic-ui/react/toolbar";
import { ChevronIcon, CloseIcon, ZoomInIcon, ZoomOutIcon } from "./_icons";
import { classes } from "./_classes";

type Album = {
  id: string;
  src: string;
  thumb: string;
  title: string;
  artist: string;
};

const cover = (id: number, size: number) =>
  `https://picsum.photos/id/${id}/${size}/${size}`;

const albums: Album[] = [
  [1022, "Northern Lights", "Aurora Field"],
  [1041, "Low Tide", "The Undertow"],
  [1042, "Long Exposure", "Night Shift"],
  [1079, "Ember", "Saltwater Kids"],
  [1016, "Red Rock", "Desert Lines"],
  [1064, "Above the Fog", "Morning Choir"],
  [1002, "Open Water", "Blue Hour"],
  [1036, "Base Camp", "High Country"],
  [1057, "Coast Road", "The Wanderers"],
  [1047, "Back Alley", "Brick & Mortar"],
].map(([id, title, artist]) => ({
  id: String(id),
  src: cover(id as number, 1000),
  thumb: cover(id as number, 300),
  title: title as string,
  artist: artist as string,
}));

/** Tailwind classes of this demo. */
const local = {
  Shelf: "grid w-full max-w-xl grid-cols-3 gap-4 select-none sm:grid-cols-5",
  Album: "flex min-w-0 flex-col gap-2",
  Cover:
    "aspect-square w-full rounded-lg shadow-lg shadow-black/40 ring-1 ring-white/10 transition-opacity hover:opacity-80",
  AlbumTitle: "m-0 truncate text-sm font-medium",
  Artist: "m-0 truncate text-xs text-neutral-500",
  Popup:
    "isolate overflow-hidden [--cover-size:min(40dvh,58vw,23rem)] [--cover-y:41dvh] [@media(max-height:500px)]:[--cover-y:36dvh]",
  Ambient:
    "pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(ellipse_at_50%_40%,rgb(90_100_130/0.18),transparent_65%)] opacity-[calc(1_-_var(--lightbox-dismiss-progress,0))] transition-opacity duration-(--lightbox-flight-duration) ease-(--lightbox-flight-easing) in-data-swiping:transition-none in-data-starting-style:opacity-0 in-data-ending-style:opacity-0 motion-reduce:transition-none",
  // Pan bounds use the whole viewer, not the smaller resting cover. While a
  // finger holds a cover, and while it flies home, it is drawn above
  // everything else: the reflection and the controls. One slide of finger
  // travel is `--lightbox-swipe-distance`, so the covers follow the finger.
  Viewport:
    "absolute inset-0 cursor-grab gap-x-0 select-none [--lightbox-swipe-distance:calc(var(--cover-size)*2.2)] data-swiping:z-10 data-swiping:cursor-grabbing in-data-ending-style:z-10",
  /**
   * Placement, rotation and lifecycle motion are separate layers. The cover
   * moves linearly (`--l`), 0.68 of a cover per slide next to the centre, so
   * it follows the finger with `--lightbox-swipe-distance`; a smoothstep curve (`--t`)
   * eases only its rotation and depth into the wings.
   * Parallel slides sort the flattened covers without intersecting planes.
   */
  Slide:
    "group/cover px-[calc((100%_-_var(--cover-size))/2)] pt-[calc(var(--cover-y)_-_var(--cover-size)/2)] pb-[calc(100dvh_-_var(--cover-y)_-_var(--cover-size)/2)] [--p:var(--lightbox-slide-progress)] [--l:clamp(-1,var(--p),1)] [--distance:max(var(--p),-1*var(--p))] [--t:calc(clamp(-1,var(--p),1)*min(1,var(--distance))*(3_-_2*min(1,var(--distance))))] [--depth:max(var(--t),-1*var(--t))] [transform:translateX(calc((var(--l)*0.68_+_(var(--p)_-_var(--l))*0.24)*var(--cover-size)_-_var(--p)*100%))_translateZ(calc(var(--distance)*-1px))] [opacity:clamp(0,calc(3.5_-_var(--distance)),1)] motion-reduce:transform-none motion-reduce:opacity-100",
  /**
   * Each cover tilts inward in its own perspective, flattened into its slide.
   * Depth scales with the cover, so phones and desktops share the same shape.
   */
  Turn: "absolute inset-0 origin-[50%_var(--cover-y)] [transform:perspective(calc(var(--cover-size)*4))_translateZ(calc(var(--depth)*var(--cover-size)*-0.65))_rotateY(calc(var(--t)*-58deg))] [filter:brightness(calc(1_-_var(--depth)*0.38))] motion-reduce:transform-none motion-reduce:filter-none",
  // Only the wings fold away: the active media keeps its own return flight.
  // While the cover shown is pulled to dismiss, the others follow it partway
  // (`--lightbox-dismiss-movement-x` and `-y`), shrink a little and dim. As it
  // flies home they fly to its tile too: each cancels its own distance from
  // the centre and moves by `--lightbox-flight-movement-x` and `-y`, scaled
  // with `--lightbox-flight-scale`, fading out, timed with the flight.
  Surface:
    "absolute inset-0 origin-[50%_var(--cover-y)] transition-[opacity,translate,scale] duration-(--lightbox-flight-duration) ease-(--lightbox-flight-easing) group-not-data-active/cover:[translate:calc(var(--lightbox-dismiss-movement-x,0px)*0.15)_calc(var(--lightbox-dismiss-movement-y,0px)*0.15)] group-not-data-active/cover:scale-[calc(1_-_0.1*var(--lightbox-dismiss-progress,0))] group-not-data-active/cover:opacity-[calc(1_-_0.75*var(--lightbox-dismiss-progress,0))] in-data-swiping:transition-none in-data-starting-style:group-not-data-active/cover:translate-x-[calc(var(--t)*var(--cover-size)*-0.2)] in-data-starting-style:group-not-data-active/cover:scale-85 in-data-starting-style:group-not-data-active/cover:opacity-0 in-data-ending-style:group-not-data-active/cover:[translate:calc(var(--lightbox-flight-movement-x,0px)_-_(var(--l)*0.68_+_(var(--p)_-_var(--l))*0.24)*var(--cover-size))_var(--lightbox-flight-movement-y,0px)] in-data-ending-style:group-not-data-active/cover:scale-[calc(var(--lightbox-flight-scale,1)*0.05)] in-data-ending-style:group-not-data-active/cover:duration-[calc(var(--lightbox-flight-duration)*0.6)] in-data-ending-style:group-not-data-active/cover:opacity-0 in-data-zoomed:group-not-data-active/cover:scale-95 in-data-zoomed:group-not-data-active/cover:opacity-0 motion-reduce:transition-none",
  // Keep overflow visible: zoom and the return flight leave this box.
  Media: "rounded-xl",
  // Zoom and pan transform the image, so its frame must live here too.
  Image: "rounded-[inherit] shadow-2xl shadow-black/50 ring-1 ring-white/10",
  Reflection:
    "pointer-events-none absolute top-[calc(var(--cover-y)+var(--cover-size)/2+0.75rem)] left-1/2 h-[calc(var(--cover-size)/2)] w-(--cover-size) -translate-x-1/2 overflow-hidden mask-b-from-0% mask-b-to-100% opacity-[calc(0.15*(1_-_var(--lightbox-dismiss-progress,0)*2))] transition-opacity duration-(--lightbox-flight-duration) ease-(--lightbox-flight-easing) in-data-swiping:transition-none in-data-starting-style:opacity-0 in-data-ending-style:opacity-0 in-data-zoomed:opacity-0 motion-reduce:hidden motion-reduce:transition-none",
  ReflectionImage: "block aspect-square w-full -scale-y-100 rounded-xl",
  Footer: cn(
    classes.controlBottom,
    "absolute inset-x-0 top-[calc(var(--cover-y)+var(--cover-size)/2+2.5rem)] flex flex-col items-center gap-5 px-4 [@media(max-height:500px)]:top-[calc(var(--cover-y)+var(--cover-size)/2+1rem)] [@media(max-height:500px)]:gap-2",
  ),
  Text: "flex min-h-12 flex-col items-center text-center transition-opacity duration-200 in-data-zoomed:opacity-0 motion-reduce:transition-none",
  Title: cn("m-0 text-xl font-semibold tracking-tight", classes.enter),
  Description: cn("m-0 text-sm text-neutral-400", classes.enter),
  Navigation: "flex items-center gap-4",
  ZoomReset: cn(
    classes.toolbarValue,
    "h-9 cursor-pointer rounded-full border-none bg-transparent px-2 hover:not-data-disabled:bg-white/10 focus-visible:outline-2 focus-visible:outline-white",
  ),
  Hint: "m-0 text-xs text-neutral-500 transition-opacity duration-200 in-data-zoomed:opacity-0 motion-reduce:transition-none [@media(max-height:500px)]:hidden",
};

/** Album covers in a coverflow: a 3D transition in CSS, from one variable. */
export default function ExampleLightboxCoverflow(): React.ReactElement {
  return (
    <Lightbox.Root items={albums}>
      <div className={local.Shelf}>
        {albums.map((album) => (
          <div key={album.id} className={local.Album}>
            <Lightbox.Trigger
              value={album.id}
              aria-label={`Open ${album.title} by ${album.artist}`}
              className={cn(classes.trigger, local.Cover)}
            >
              <img
                loading="lazy"
                src={album.thumb}
                alt=""
                className={classes.triggerImage}
              />
            </Lightbox.Trigger>
            <div>
              <p className={local.AlbumTitle}>{album.title}</p>
              <p className={local.Artist}>{album.artist}</p>
            </div>
          </div>
        ))}
      </div>

      <Lightbox.Portal>
        <Lightbox.Backdrop className={classes.backdrop} />
        <Lightbox.Popup className={cn(classes.popup, local.Popup)}>
          <div aria-hidden="true" className={local.Ambient} />
          <Lightbox.Viewport
            aria-label="Albums"
            neighbors={3}
            className={local.Viewport}
          >
            {(album: Album) => (
              <Lightbox.Slide className={local.Slide}>
                <div className={local.Turn}>
                  <div className={local.Surface}>
                    <Lightbox.Media
                      width={1000}
                      height={1000}
                      className={local.Media}
                    >
                      <img
                        src={album.src}
                        alt={`${album.title} cover`}
                        className={local.Image}
                      />
                    </Lightbox.Media>
                    <div aria-hidden="true" className={local.Reflection}>
                      <img
                        src={album.src}
                        alt=""
                        draggable={false}
                        className={local.ReflectionImage}
                      />
                    </div>
                  </div>
                </div>
              </Lightbox.Slide>
            )}
          </Lightbox.Viewport>

          <div className={cn(classes.topBar, classes.controlTop)}>
            <Lightbox.Counter className={classes.counter} />
            <Lightbox.Close className={classes.iconButton}>
              <CloseIcon />
            </Lightbox.Close>
          </div>

          <footer className={local.Footer}>
            <div className={local.Text}>
              <Lightbox.Title className={local.Title}>
                {(album: Album) => album.title}
              </Lightbox.Title>
              <Lightbox.Description className={local.Description}>
                {(album: Album) => album.artist}
              </Lightbox.Description>
            </div>
            <div className={local.Navigation}>
              <Lightbox.Previous className={classes.iconButton}>
                <ChevronIcon direction="left" />
              </Lightbox.Previous>
              <Toolbar.Root aria-label="Album zoom" className={classes.toolbar}>
                <Lightbox.ZoomOut
                  render={<Toolbar.Button />}
                  className={classes.toolbarButton}
                >
                  <ZoomOutIcon />
                </Lightbox.ZoomOut>
                <Lightbox.ZoomReset
                  render={<Toolbar.Button />}
                  className={local.ZoomReset}
                >
                  <Lightbox.ZoomValue />
                </Lightbox.ZoomReset>
                <Lightbox.ZoomIn
                  render={<Toolbar.Button />}
                  className={classes.toolbarButton}
                >
                  <ZoomInIcon />
                </Lightbox.ZoomIn>
              </Toolbar.Root>
              <Lightbox.Next className={classes.iconButton}>
                <ChevronIcon direction="right" />
              </Lightbox.Next>
            </div>
            <p className={local.Hint}>
              Swipe or use arrow keys · Pinch or double-click to zoom
            </p>
          </footer>
        </Lightbox.Popup>
      </Lightbox.Portal>
    </Lightbox.Root>
  );
}
```

```tsx title="Coverflow"
// The classes and the progress curve are in the demo's source.
<Lightbox.Viewport
  neighbors={3}
  className="[--lightbox-swipe-distance:calc(var(--cover-size)*2.2)]"
>
  {(album) => (
    <Lightbox.Slide className={local.Slide}>
      <div className={local.Turn}>
        <div className={local.Surface}>
          <Lightbox.Media width={1000} height={1000} className={local.Media}>
            <img
              src={album.src}
              alt={`${album.title} cover`}
              className={local.Image}
            />
          </Lightbox.Media>
          <div aria-hidden="true" className={local.Reflection}>
            <img
              src={album.src}
              alt=""
              draggable={false}
              className={local.ReflectionImage}
            />
          </div>
        </div>
      </div>
    </Lightbox.Slide>
  )}
</Lightbox.Viewport>
```

### 3D models

An armory of live 3D weapons, rendered with Google's [`<model-viewer>`](https://modelviewer.dev) and grouped by class. A weapon turns on its tile under the pointer, and they all open in one viewer: drag to rotate the weapon shown, and move between them with the arrows, the cards or ← →. The cards are grouped by class too, and the indicator, corner brackets with a lit bar, glides along with the slides. Escape or the close button flies the weapon back to its tile, turned the way you left it.

`Lightbox.Media` takes any content, so the model's element goes inside it with a `width` and `height` for its shape. Only images zoom, so the zoom gestures leave it alone. Its drags would normally swipe or pull the viewer closed: mark the element with Base UI's `data-base-ui-swipe-ignore` and the viewer leaves every press on it to the model. Set it to `"x"` or `"y"` to keep only the drags along that axis, so a drag along the other one still swipes or pulls, as in a Base UI drawer.

The viewer carries on from the tile, mid-turn and mid-animation, because it shows the same `<model-viewer>` element: the demo creates one per model outside React and moves it between its tile and its slide. Nothing is copied or loaded twice.

- **Ready before it flies.** Before it opens, the viewer is mounted out of sight. A ref callback on each slide moves its model in, and `onOpenChange` returns a Promise that waits until the model shown has drawn itself at the viewer's size. Only then does it fly, so its first frame is already sharp and the right size.
- **Never an empty tile.** A weapon that leaves its tile leaves a still of itself, from `toDataURL()`, in its place. When the viewer closes, the still is taken again, so the tile shows the model as it was turned. Once the model is back and has drawn, the still goes. The thumbnails are stills too.
- **Smooth flights.** Every model holds still during the flights, so it isn't redrawn on every frame of them. Once the viewer has landed, only the model shown moves.
- **The angle you left it at.** On its own, a moved `<model-viewer>` goes back to its `camera-orbit`. The demo writes the current orbit into it before each move.

```tsx title="models.tsx"
"use client";
import * as React from "react";
import { Lightbox, type LightboxItemValue } from "@logic-ui/react/lightbox";
import { useStableCallback } from "@logic-ui/react/utils";
import { cn } from "cn";
import { ChevronIcon, CloseIcon } from "./_icons";

type WeaponClass =
  | "Assault rifle"
  | "Submachine gun"
  | "Marksman rifle"
  | "Sniper rifle"
  | "Sidearm"
  | "Launcher";

type Weapon = {
  id: string;
  src: string;
  name: string;
  weaponClass: WeaponClass;
  /** Primary or secondary slot of the loadout. */
  slot: "Primary" | "Secondary";
};

const asset = (id: string) => `https://static.poly.pizza/${id}.glb`;

/** Models by austincford on Poly Pizza, CC BY 3.0. */
const weapons: Weapon[] = [
  {
    id: "mdr",
    src: asset("ca730343-aade-4dff-9dfc-f9252715c1ca"),
    name: "MDR",
    weaponClass: "Assault rifle",
    slot: "Primary",
  },
  {
    id: "mpx",
    src: asset("b74662ef-4f3c-4e79-a059-190f8f92a5c4"),
    name: "MPX",
    weaponClass: "Submachine gun",
    slot: "Primary",
  },
  {
    id: "mpsd",
    src: asset("31e903e2-89ed-41ee-94ad-d88ceb08104c"),
    name: "MPSD",
    weaponClass: "Submachine gun",
    slot: "Primary",
  },
  {
    id: "mk14",
    src: asset("46945263-4815-4c5c-b802-a71ed9f0e2fb"),
    name: "MK14",
    weaponClass: "Marksman rifle",
    slot: "Primary",
  },
  {
    id: "m21",
    src: asset("b4fb3a42-2f16-47cb-a05d-a2d99ea4f0d3"),
    name: "M21 EBR",
    weaponClass: "Marksman rifle",
    slot: "Primary",
  },
  {
    id: "srsa1",
    src: asset("2417d371-8951-4049-8f79-b085869920bb"),
    name: "SRSA1",
    weaponClass: "Sniper rifle",
    slot: "Primary",
  },
  {
    id: "prc",
    src: asset("55a7dfe7-02c3-45a4-95d4-b6fdca2cd4de"),
    name: "Precision rifle",
    weaponClass: "Sniper rifle",
    slot: "Primary",
  },
  {
    id: "p320",
    src: asset("7f0c0ad8-fa76-4f87-b6ac-356b3da3025a"),
    name: "P320",
    weaponClass: "Sidearm",
    slot: "Secondary",
  },
  {
    id: "p226",
    src: asset("adfb7fcb-343d-42b2-8546-704606a66f30"),
    name: "P226",
    weaponClass: "Sidearm",
    slot: "Secondary",
  },
  {
    id: "revolver",
    src: asset("9d476060-01ae-4e47-bca6-dadb8e43c3f7"),
    name: "Revolver",
    weaponClass: "Sidearm",
    slot: "Secondary",
  },
  {
    id: "rpg",
    src: asset("07fbe965-5ca5-4d46-8198-fbbe27c23a00"),
    name: "RPG",
    weaponClass: "Launcher",
    slot: "Secondary",
  },
];

/** The weapons, class by class, in the order of `weapons`. */
const groups = weapons.reduce<{ weaponClass: WeaponClass; items: Weapon[] }[]>(
  (result, weapon) => {
    const last = result.at(-1);
    if (last?.weaponClass === weapon.weaponClass) last.items.push(weapon);
    else result.push({ weaponClass: weapon.weaponClass, items: [weapon] });
    return result;
  },
  [],
);

/** Whether the weapon opens its class in the strip of cards. */
function isFirstOfClass(weapon: Weapon): boolean {
  const index = weapons.indexOf(weapon);
  return weapons[index - 1]?.weaponClass !== weapon.weaponClass;
}

const pad = (value: number) => String(value).padStart(2, "0");

/**
 * Fades with the viewer: in after it opens, out as it closes or is pulled
 * away.
 */
const control =
  "opacity-[calc(1_-_var(--lightbox-dismiss-progress,0)*1.5)] transition-[opacity,translate] duration-(--lightbox-flight-duration) ease-(--lightbox-flight-easing) in-data-swiping:transition-none in-data-starting-style:opacity-0 in-data-ending-style:pointer-events-none in-data-ending-style:opacity-0";

// Corner brackets in `--bracket`, drawn with gradients: a short line along
// each edge from each corner. Written out, so Tailwind finds them.
const tileBrackets =
  "before:[background:linear-gradient(var(--bracket),var(--bracket))_top_left/10px_1px_no-repeat,linear-gradient(var(--bracket),var(--bracket))_top_left/1px_10px_no-repeat,linear-gradient(var(--bracket),var(--bracket))_top_right/10px_1px_no-repeat,linear-gradient(var(--bracket),var(--bracket))_top_right/1px_10px_no-repeat,linear-gradient(var(--bracket),var(--bracket))_bottom_left/10px_1px_no-repeat,linear-gradient(var(--bracket),var(--bracket))_bottom_left/1px_10px_no-repeat,linear-gradient(var(--bracket),var(--bracket))_bottom_right/10px_1px_no-repeat,linear-gradient(var(--bracket),var(--bracket))_bottom_right/1px_10px_no-repeat]";
const cardBrackets =
  "[background:linear-gradient(var(--bracket),var(--bracket))_top_left/12px_1px_no-repeat,linear-gradient(var(--bracket),var(--bracket))_top_left/1px_12px_no-repeat,linear-gradient(var(--bracket),var(--bracket))_top_right/12px_1px_no-repeat,linear-gradient(var(--bracket),var(--bracket))_top_right/1px_12px_no-repeat,linear-gradient(var(--bracket),var(--bracket))_bottom_left/12px_1px_no-repeat,linear-gradient(var(--bracket),var(--bracket))_bottom_left/1px_12px_no-repeat,linear-gradient(var(--bracket),var(--bracket))_bottom_right/12px_1px_no-repeat,linear-gradient(var(--bracket),var(--bracket))_bottom_right/1px_12px_no-repeat]";

/** Tailwind classes of this demo. */
const local = {
  Armory:
    "flex w-full max-w-2xl flex-col gap-6 border border-white/8 bg-[#0b0d0c] p-5 text-[#d9dfd2] select-none",
  Heading: "flex items-baseline justify-between border-b border-white/8 pb-3",
  HeadingTitle: "m-0 text-xl font-black tracking-[0.18em] text-white uppercase",
  HeadingMeta: "font-mono text-[0.6875rem] tracking-widest text-[#c6f432]",
  Group: "flex flex-col gap-2",
  GroupLabel:
    "m-0 flex items-center gap-3 font-mono text-[0.6875rem] tracking-[0.25em] text-[#8a9384] uppercase after:h-px after:flex-1 after:bg-white/8",
  Grid: "grid grid-cols-2 gap-2 sm:grid-cols-3",
  Card: "flex min-w-0 flex-col",
  // The live model doesn't take the pointer, so a press on the tile opens
  // the viewer. Corner brackets light up under the pointer.
  Trigger: cn(
    "group relative aspect-[16/10] w-full cursor-pointer overflow-hidden border border-white/8 bg-[radial-gradient(ellipse_at_50%_60%,rgb(198_244_50/0.07),transparent_70%),linear-gradient(#121513,#0d0f0e)] p-0 transition-[border-color] duration-200 hover:border-[#c6f432]/40 data-flying:invisible data-popup-open:invisible focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#c6f432]",
    "before:pointer-events-none before:absolute before:inset-1 before:opacity-0 before:transition-opacity before:duration-200 before:[--bracket:#c6f432] hover:before:opacity-100",
    tileBrackets,
  ),
  TileNumber:
    "absolute top-2 left-2.5 font-mono text-[0.625rem] tracking-widest text-[#8a9384]",
  // Where the model sits, and the still of it that holds its place while it
  // is in the viewer.
  TileHost:
    "pointer-events-none relative size-full [&>img]:absolute [&>img]:inset-0 [&>img]:size-full [&>model-viewer]:size-full",
  CardName:
    "m-0 mt-1.5 truncate text-[0.8125rem] font-bold tracking-[0.12em] text-white uppercase",
  CardClass:
    "m-0 truncate font-mono text-[0.625rem] tracking-[0.2em] text-[#8a9384] uppercase",
  Credit: "m-0 font-mono text-[0.625rem] text-[#5f665b]",

  // The viewer.
  Backdrop:
    "fixed inset-0 bg-[rgb(6_8_7/calc(0.985*(1_-_var(--lightbox-dismiss-progress,0))))] backdrop-blur-[calc(16px*(1_-_var(--lightbox-dismiss-progress,0)))] transition-[background-color,backdrop-filter] duration-(--lightbox-flight-duration) ease-(--lightbox-flight-easing) data-swiping:transition-none data-starting-style:bg-transparent data-starting-style:backdrop-blur-none data-ending-style:bg-transparent data-ending-style:backdrop-blur-none",
  Popup: "fixed inset-0 text-[#d9dfd2] select-none outline-none",
  // A spotlight on a grid floor, behind the weapon.
  Stage: cn(
    "pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_45%_40%_at_50%_45%,rgb(198_244_50/0.09),transparent_70%),linear-gradient(rgb(255_255_255/0.035)_1px,transparent_1px),linear-gradient(90deg,rgb(255_255_255/0.035)_1px,transparent_1px)] bg-size-[100%_100%,56px_56px,56px_56px] mask-radial-[70%_65%] mask-radial-from-20% mask-radial-at-[50%_45%]",
    control,
  ),
  Viewport: "absolute inset-0 select-none",
  Slide:
    "px-[max(1rem,env(safe-area-inset-left))] pt-[calc(7rem+env(safe-area-inset-top))] pb-[calc(10.5rem+env(safe-area-inset-bottom))] md:px-32",
  // Drag to rotate: `data-base-ui-swipe-ignore` keeps every drag on the
  // weapon, so it never swipes or pulls the viewer to close. The arrows, the
  // cards and ← → move between the weapons.
  ViewerHost:
    "size-full cursor-grab active:cursor-grabbing [&>model-viewer]:size-full",
  TopBar: cn(
    "absolute inset-x-0 top-0 flex items-start justify-between gap-4 px-[max(1.25rem,env(safe-area-inset-left))] pt-[calc(1.25rem+env(safe-area-inset-top))]",
    control,
    "in-data-starting-style:-translate-y-3 in-data-ending-style:-translate-y-3",
  ),
  Breadcrumb:
    "m-0 flex items-center gap-2 font-mono text-[0.6875rem] tracking-[0.25em] whitespace-nowrap text-[#8a9384] uppercase [&_b]:font-normal [&_b]:text-[#c6f432]",
  // The name slides in from the side the weapon came from.
  Name: "m-0 mt-1 text-4xl leading-none font-black tracking-[0.08em] text-white uppercase transition-[opacity,translate] duration-300 ease-out data-starting-style:opacity-0 data-starting-style:transition-none data-starting-style:data-[activation-direction=left]:-translate-x-6 data-starting-style:data-[activation-direction=right]:translate-x-6 sm:text-5xl",
  // Not on phones, where the cards below show where you are.
  Counter:
    "hidden font-mono text-sm tracking-widest whitespace-nowrap sm:block text-[#8a9384] tabular-nums [&_b]:font-normal [&_b]:text-white",
  TopActions: "flex items-center gap-4",
  Square:
    "flex size-10 cursor-pointer items-center justify-center border border-white/15 bg-black/40 text-white transition-[border-color,background-color] duration-150 hover:border-[#c6f432] hover:bg-[#c6f432]/10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#c6f432] [&_svg]:size-4",
  Side: cn("absolute top-1/2 z-10 -mt-6 hidden md:block", control),
  // A square that fills with the accent.
  Arrow:
    "group flex cursor-pointer border-none bg-transparent p-0 text-white data-disabled:pointer-events-none data-disabled:opacity-20",
  ArrowBox:
    "flex size-12 items-center justify-center border border-white/20 bg-black/40 transition-[border-color,background-color,color,scale] duration-150 group-hover:border-[#c6f432] group-hover:bg-[#c6f432] group-hover:text-black group-active:scale-95 group-focus-visible:outline-2 group-focus-visible:outline-offset-2 group-focus-visible:outline-[#c6f432] [&_svg]:size-5",
  Footer: cn(
    "absolute inset-x-0 bottom-0 flex flex-col items-center gap-3 px-3 pb-[calc(1.25rem+env(safe-area-inset-bottom))]",
    control,
    "in-data-starting-style:translate-y-3 in-data-ending-style:translate-y-3",
  ),
  // `px-[50%]` of the tray lets the first and last cards reach its centre,
  // where the active one is scrolled to.
  Tray: "w-[min(100%,46rem)] border-y border-white/8 bg-black/50 backdrop-blur-md",
  Strip:
    "relative flex items-end gap-1.5 overflow-x-auto px-[50%] pt-7 pb-3.5 [scrollbar-width:none] data-overflow-end:mask-r-from-85% data-overflow-start:mask-l-from-85% [&::-webkit-scrollbar]:hidden",
  // A class opens with a gap, and its name above its first card.
  Thumbnail: cn(
    "relative flex h-16 w-28 flex-none cursor-pointer flex-col border border-white/8 bg-[linear-gradient(#141715,#0e100f)] p-1.5 text-left transition-[opacity,border-color,translate] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)]",
    "opacity-55 hover:not-data-active:-translate-y-0.5 hover:not-data-active:border-white/20 hover:not-data-active:opacity-90 data-active:border-transparent data-active:opacity-100",
    "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#c6f432]",
    "data-first-of-class:not-first:ml-5",
  ),
  ThumbnailClass:
    "absolute -top-5 left-0 font-mono text-[0.5625rem] tracking-[0.25em] whitespace-nowrap text-[#c6f432] uppercase",
  ThumbnailImage:
    "pointer-events-none absolute inset-x-1 top-0.5 bottom-3.5 m-auto max-h-full max-w-full object-contain drop-shadow-[0_3px_4px_rgb(0_0_0/0.6)]",
  ThumbnailName:
    "relative mt-auto truncate font-mono text-[0.5625rem] tracking-[0.15em] text-white uppercase",
  // Glides to the active card with the slides: accent brackets, a glow
  // inside, and a lit bar under it.
  Indicator: cn(
    "pointer-events-none absolute top-(--active-thumbnail-top) left-(--active-thumbnail-left) h-(--active-thumbnail-height) w-(--active-thumbnail-width) [--bracket:#c6f432]",
    cardBrackets,
    "shadow-[inset_0_-24px_32px_-20px_rgb(198_244_50/0.25)]",
    "after:absolute after:inset-x-3 after:-bottom-2 after:h-0.5 after:bg-[#c6f432] after:shadow-[0_0_12px_2px_rgb(198_244_50/0.6)]",
  ),
  Hint: "m-0 hidden font-mono text-[0.625rem] tracking-[0.2em] text-[#5f665b] uppercase sm:block",
};

/** The parts of `<model-viewer>`'s API the demo uses, once it is upgraded. */
type ModelViewerElement = HTMLElement & {
  loaded?: boolean;
  toDataURL?: (type?: string) => string;
  getCameraOrbit?: () => { toString: () => string };
  cameraOrbit?: string;
  jumpCameraToGoal?: () => void;
};

function nextFrame() {
  return new Promise<void>((resolve) => {
    requestAnimationFrame(() => resolve());
  });
}

/**
 * A `<model-viewer>` for the weapon, made outside React, side on with the
 * muzzle to the left.
 */
function createModelElement(weapon: Weapon): ModelViewerElement {
  const element = document.createElement("model-viewer");
  element.setAttribute("src", weapon.src);
  element.setAttribute("alt", `3D model: ${weapon.name}`);
  // Loads once its tile, or its slide, comes into view.
  element.setAttribute("loading", "lazy");
  element.setAttribute("camera-orbit", "-90deg 80deg auto");
  element.setAttribute("auto-rotate-delay", "0");
  element.setAttribute("rotation-per-second", "30deg");
  element.setAttribute("interaction-prompt", "none");
  element.setAttribute("disable-zoom", "");
  element.setAttribute("disable-pan", "");
  element.setAttribute("environment-image", "neutral");
  element.setAttribute("exposure", "1.1");
  element.setAttribute("shadow-intensity", "1.2");
  element.setAttribute("shadow-softness", "0.8");
  return element;
}

/**
 * Puts the model in `host`, rotatable by drag only in the viewer, and waits
 * until it has drawn itself at its new size: its canvas follows a frame later.
 */
async function moveModel(
  element: HTMLElement | undefined,
  host: HTMLElement | null | undefined,
) {
  const model = element as ModelViewerElement | undefined;
  if (!model || !host || model.parentElement === host) return;
  // Kept through the move: on its own it goes back to `camera-orbit`, and
  // loses the angle it was dragged to. Only once loaded: before, the orbit
  // isn't set from `camera-orbit` yet, and would replace it.
  const orbit = model.loaded ? model.getCameraOrbit?.().toString() : undefined;
  if (orbit) model.cameraOrbit = orbit;
  model.toggleAttribute("camera-controls", host.dataset.viewer !== undefined);
  host.append(model);
  model.jumpCameraToGoal?.();
  await nextFrame();
  await nextFrame();
}

/**
 * Lets the model turn, or holds it still. A still model isn't drawn again on
 * every frame, so the flights stay smooth.
 */
function setModelMoving(element: HTMLElement | undefined, moving: boolean) {
  element?.toggleAttribute("auto-rotate", moving);
}

/** A picture of the model as it is now, or `null` before it has loaded. */
function takeStill(element: HTMLElement | undefined): string | null {
  const model = element as ModelViewerElement | undefined;
  if (!model?.loaded) return null;
  return model.toDataURL?.("image/png") ?? null;
}

/**
 * Shows a still of the model in its tile, in place of the model while it is
 * away or settling back in: the tile never shows empty.
 */
function showStill(element: HTMLElement | undefined, tile: HTMLElement | null) {
  const src = takeStill(element);
  if (!tile || !src) return;
  const still = tile.querySelector("img") ?? document.createElement("img");
  still.alt = "";
  still.src = src;
  tile.append(still);
}

function hideStill(tile: HTMLElement | null) {
  tile?.querySelector("img")?.remove();
}

/**
 * An armory of 3D weapons. A weapon turns on its tile under the pointer and
 * opens in one viewer, to be rotated. Dragging rotates it instead of swiping
 * or closing the viewer; the arrows, the cards and ← → move between them,
 * and Escape flies the one shown back to its tile, turned as you left it.
 */
export default function ExampleLightboxModels(): React.ReactElement {
  // One `<model-viewer>` per weapon, for the page: the same element moves
  // between its tile and its slide, so the viewer shows it exactly as it
  // was, and nothing is loaded twice.
  const elementsRef = React.useRef(new Map<string, ModelViewerElement>());
  const tilesRef = React.useRef(new Map<string, HTMLElement>());
  const hostsRef = React.useRef(new Map<string, HTMLElement>());
  const hostRefs = React.useRef(
    new Map<
      string,
      (host: HTMLDivElement | null) => (() => void) | undefined
    >(),
  );
  const [value, setValue] = React.useState<LightboxItemValue>(weapons[0].id);
  // At once: a trigger reports its weapon just before the viewer opens.
  const valueRef = React.useRef(value);
  const [hovered, setHovered] = React.useState<string | null>(null);
  // On the page, flying, or open with the weapon shown landed.
  const [stage, setStage] = React.useState<"closed" | "flying" | "open">(
    "closed",
  );
  const [thumbnails, setThumbnails] = React.useState<Record<string, string>>(
    {},
  );

  React.useEffect(() => {
    // Registers the custom element; the elements are upgraded once it is.
    // Always at full resolution: on a slow GPU it would otherwise switch
    // resolution mid-flight, and the weapon would blink.
    void import("@google/model-viewer").then(({ ModelViewerElement }) => {
      ModelViewerElement.minimumRenderScale = 1;
    });
    const elements = elementsRef.current;
    for (const weapon of weapons) {
      const element = createModelElement(weapon);
      elements.set(weapon.id, element);
      void moveModel(element, tilesRef.current.get(weapon.id));
    }
    return () => {
      for (const element of elements.values()) element.remove();
      elements.clear();
    };
  }, []);

  // On the page, the weapon under the pointer turns. In the viewer, the one
  // shown turns once it has landed. Nothing moves in flight.
  React.useEffect(() => {
    for (const [id, element] of elementsRef.current)
      setModelMoving(
        element,
        stage === "closed" ? id === hovered : stage === "open" && id === value,
      );
  }, [stage, value, hovered]);

  /** Back in its tile, where its still holds its place until it has drawn. */
  const returnModel = useStableCallback(async (id: string) => {
    const tile = tilesRef.current.get(id) ?? null;
    const element = elementsRef.current.get(id);
    await moveModel(element, tile);
    // Unless it has left again meanwhile, for a slide that mounted again.
    if (element?.parentElement === tile) hideStill(tile);
  });

  /**
   * The weapon's place in its slide, one stable ref per weapon. A slide is
   * there for the weapon shown and its neighbours: the model moves in when it
   * mounts, leaving a still in its tile, and goes home when it unmounts.
   */
  const getHostRef = (id: string) => {
    const cached = hostRefs.current.get(id);
    if (cached) return cached;
    const ref = (host: HTMLDivElement | null) => {
      if (!host) return undefined;
      const element = elementsRef.current.get(id);
      hostsRef.current.set(id, host);
      showStill(element, tilesRef.current.get(id) ?? null);
      setModelMoving(element, false);
      void moveModel(element, host);
      return () => {
        if (hostsRef.current.get(id) === host) hostsRef.current.delete(id);
        if (element?.parentElement === host) void returnModel(id);
      };
    };
    hostRefs.current.set(id, ref);
    return ref;
  };

  return (
    <Lightbox.Root
      items={weapons}
      maxScale={1}
      value={value}
      onValueChange={(next) => {
        valueRef.current = next;
        setValue(next);
      }}
      onOpenChange={async (open) => {
        setStage("flying");
        const elements = elementsRef.current;
        // Still while it flies, both ways.
        for (const element of elements.values()) setModelMoving(element, false);
        if (!open) {
          // The tiles show the weapons as they were turned in the viewer,
          // for when they land.
          for (const id of hostsRef.current.keys())
            showStill(elements.get(id), tilesRef.current.get(id) ?? null);
          return;
        }
        // The cards: each weapon as it is on its tile now.
        const stills: Record<string, string> = {};
        for (const [id, element] of elements) {
          const still = takeStill(element);
          if (still) stills[id] = still;
        }
        setThumbnails(stills);
        // The viewer is mounted out of sight before it opens, and waits for
        // this: the weapon moves into its slide and draws at its size, then
        // flies out of its tile.
        const id = String(valueRef.current);
        const element = elements.get(id);
        for (
          let frame = 0;
          element?.parentElement !== hostsRef.current.get(id) && frame < 60;
          frame += 1
        )
          await nextFrame();
        await nextFrame();
        await nextFrame();
      }}
      onOpenChangeComplete={(open) => setStage(open ? "open" : "closed")}
    >
      <section aria-label="Armory" className={local.Armory}>
        <header className={local.Heading}>
          <h3 className={local.HeadingTitle}>Armory</h3>
          <span className={local.HeadingMeta}>
            {pad(weapons.length)} weapons
          </span>
        </header>
        {groups.map((group) => (
          <div key={group.weaponClass} className={local.Group}>
            <p className={local.GroupLabel}>
              {group.items[0].slot} · {group.weaponClass}
            </p>
            <div className={local.Grid}>
              {group.items.map((weapon) => (
                <div key={weapon.id} className={local.Card}>
                  <Lightbox.Trigger
                    value={weapon.id}
                    aria-label={`Inspect the ${weapon.name}`}
                    className={local.Trigger}
                    onPointerEnter={() => setHovered(weapon.id)}
                    onPointerLeave={() => setHovered(null)}
                    onFocus={() => setHovered(weapon.id)}
                    onBlur={() => setHovered(null)}
                  >
                    <div
                      ref={(tile) => {
                        if (tile) tilesRef.current.set(weapon.id, tile);
                        else tilesRef.current.delete(weapon.id);
                      }}
                      className={local.TileHost}
                    />
                    <span className={local.TileNumber}>
                      {pad(weapons.indexOf(weapon) + 1)}
                    </span>
                  </Lightbox.Trigger>
                  <p className={local.CardName}>{weapon.name}</p>
                  <p className={local.CardClass}>{weapon.weaponClass}</p>
                </div>
              ))}
            </div>
          </div>
        ))}
        <p className={local.Credit}>
          Models by austincford on Poly Pizza, CC BY 3.0.
        </p>
      </section>

      <Lightbox.Portal>
        <Lightbox.Backdrop className={local.Backdrop} />
        <Lightbox.Popup className={local.Popup}>
          <div className={local.Stage} />
          <Lightbox.Viewport className={local.Viewport}>
            {(weapon: Weapon) => (
              <Lightbox.Slide className={local.Slide}>
                <Lightbox.Media width={16} height={10}>
                  <div
                    ref={getHostRef(weapon.id)}
                    data-viewer=""
                    data-base-ui-swipe-ignore=""
                    className={local.ViewerHost}
                  />
                </Lightbox.Media>
              </Lightbox.Slide>
            )}
          </Lightbox.Viewport>

          <header className={local.TopBar}>
            <div>
              <Lightbox.Description className={local.Breadcrumb}>
                {(weapon: Weapon) => (
                  <>
                    {weapon.slot} <span aria-hidden>/</span>
                    <b>{weapon.weaponClass}</b>
                  </>
                )}
              </Lightbox.Description>
              <Lightbox.Title className={local.Name}>
                {(weapon: Weapon) => weapon.name}
              </Lightbox.Title>
            </div>
            <div className={local.TopActions}>
              <Lightbox.Counter className={local.Counter}>
                {(state) => (
                  <>
                    <b>{pad(state.index + 1)}</b> / {pad(state.count)}
                  </>
                )}
              </Lightbox.Counter>
              <Lightbox.Close className={local.Square}>
                <CloseIcon />
              </Lightbox.Close>
            </div>
          </header>

          <div
            className={`${local.Side} left-[max(1.5rem,env(safe-area-inset-left))] in-data-starting-style:-translate-x-3 in-data-ending-style:-translate-x-3`}
          >
            <Lightbox.Previous className={local.Arrow}>
              <span className={local.ArrowBox}>
                <ChevronIcon direction="left" />
              </span>
            </Lightbox.Previous>
          </div>
          <div
            className={`${local.Side} right-[max(1.5rem,env(safe-area-inset-right))] in-data-starting-style:translate-x-3 in-data-ending-style:translate-x-3`}
          >
            <Lightbox.Next className={local.Arrow}>
              <span className={local.ArrowBox}>
                <ChevronIcon direction="right" />
              </span>
            </Lightbox.Next>
          </div>

          <footer className={local.Footer}>
            <div className={local.Tray}>
              <Lightbox.ThumbnailList className={local.Strip}>
                {(weapon: Weapon) => (
                  <Lightbox.Thumbnail
                    aria-label={`${weapon.name}, ${weapon.weaponClass}`}
                    data-first-of-class={
                      isFirstOfClass(weapon) ? "" : undefined
                    }
                    className={local.Thumbnail}
                  >
                    {isFirstOfClass(weapon) && (
                      <span aria-hidden className={local.ThumbnailClass}>
                        {weapon.weaponClass}
                      </span>
                    )}
                    {thumbnails[weapon.id] && (
                      <img
                        src={thumbnails[weapon.id]}
                        alt=""
                        className={local.ThumbnailImage}
                      />
                    )}
                    <span aria-hidden className={local.ThumbnailName}>
                      {weapon.name}
                    </span>
                  </Lightbox.Thumbnail>
                )}
                <Lightbox.ThumbnailIndicator className={local.Indicator} />
              </Lightbox.ThumbnailList>
            </div>
            <p className={local.Hint}>
              Drag to inspect · ← → browse · Esc back
            </p>
          </footer>
        </Lightbox.Popup>
      </Lightbox.Portal>
    </Lightbox.Root>
  );
}
```

```tsx title="A 3D model"
<Lightbox.Root
  items={models}
  // The viewer waits for this before the model flies out of its tile.
  onOpenChange={async (open) => {
    if (open) await modelDrawnInViewer();
  }}
>
  …
  <Lightbox.Media width={1} height={1}>
    {/* The model's <model-viewer> moves in here when the slide mounts. */}
    <div ref={hostRef(model.id)} data-base-ui-swipe-ignore="" />
  </Lightbox.Media>
</Lightbox.Root>
```

The models are by austincford on [Poly Pizza](https://poly.pizza/u/austincford), under CC BY 3.0.

### Controlled and changing items

The viewer's state is two values, each of which you can control: whether it is open, with `open` and `onOpenChange`, and the item it shows, with `value` and `onValueChange`. Here they are shown in an address bar, as an app would put them in the URL. The value stays on the last item shown after the viewer closes, and is the first item until one is set. Changing `value` while the viewer is open slides to the new item, as Next does. A trigger calls `onValueChange` when it shows another item, then `onOpenChange`; `handle.open(value)` does the same.

`items` can change at any time, even while the viewer is open: it stays on the same item. Delete the open photo from the viewer and it moves to the neighbour; delete the last one and it closes. Add photos and the counter follows.

```tsx title="manager.tsx"
"use client";
import * as React from "react";
import { Lightbox, type LightboxItemValue } from "@logic-ui/react/lightbox";
import { getThumbnailSrc, photos, type Photo } from "./_photos";
import { CloseIcon, PlusIcon, TrashIcon } from "./_icons";
import { classes } from "./_classes";

/** Tailwind classes of this demo. */
const local = {
  Window:
    "flex w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-white/10 bg-neutral-950 select-none",
  // The address bar shows the controlled value, as an app would put it in the URL.
  AddressBar:
    "flex items-center gap-2 border-b border-white/10 bg-neutral-900/60 px-3 py-2",
  Lights:
    "flex gap-1.5 [&_span]:size-2.5 [&_span]:rounded-full [&_span]:bg-white/15",
  Address:
    "flex-1 truncate rounded-md bg-black/40 px-2.5 py-1 font-mono text-xs text-neutral-400 [&_b]:font-normal [&_b]:text-sky-300",
  Body: "flex flex-col gap-3 p-4",
  Toolbar: "flex items-center justify-between gap-3",
  Title:
    "m-0 text-sm font-semibold [&_span]:font-normal [&_span]:text-neutral-500",
  Grid: "grid grid-cols-[repeat(auto-fill,minmax(5.5rem,1fr))] gap-1.5",
  Tile: "aspect-square rounded-lg",
  Empty:
    "m-0 rounded-lg border border-dashed border-white/10 py-10 text-center text-sm text-neutral-500",
  TopActions: "flex items-center gap-2",
};

export default function ExampleLightboxManager() {
  const [items, setItems] = React.useState<Photo[]>(() => photos.slice(0, 5));
  // Whether the viewer is open, and the photo it shows: what an app keeps in
  // the URL.
  const [open, setOpen] = React.useState(false);
  const [value, setValue] = React.useState<LightboxItemValue>(photos[0].id);

  const nextPhoto = photos.find(
    (photo) => !items.some((item) => item.id === photo.id),
  );
  const add = () => {
    if (nextPhoto) setItems((current) => [...current, nextPhoto]);
  };
  // The viewer moves to the neighbour, or closes when no photo is left.
  const remove = () =>
    setItems((current) => current.filter((item) => item.id !== value));

  return (
    <section className={local.Window}>
      <div className={local.AddressBar}>
        <div className={local.Lights} aria-hidden>
          <span />
          <span />
          <span />
        </div>
        <output className={local.Address}>
          /album{open && <b>?photo={value}</b>}
        </output>
      </div>

      <div className={local.Body}>
        <div className={local.Toolbar}>
          <h3 className={local.Title}>
            Album <span>· {items.length} photos</span>
          </h3>
          <button
            type="button"
            className={classes.button}
            disabled={!nextPhoto}
            onClick={add}
          >
            <PlusIcon /> Add photo
          </button>
        </div>

        <Lightbox.Root
          items={items}
          open={open}
          onOpenChange={setOpen}
          value={value}
          onValueChange={setValue}
        >
          {items.length === 0 ? (
            <p className={local.Empty}>No photos. Add one to start.</p>
          ) : (
            <div className={local.Grid}>
              {items.map((photo) => (
                <Lightbox.Trigger
                  key={photo.id}
                  value={photo.id}
                  className={`${classes.trigger} ${local.Tile}`}
                >
                  <img
                    loading="lazy"
                    src={getThumbnailSrc(photo)}
                    alt=""
                    className={classes.triggerImage}
                  />
                </Lightbox.Trigger>
              ))}
            </div>
          )}

          <Lightbox.Portal>
            <Lightbox.Backdrop className={classes.backdrop} />
            <Lightbox.Popup className={classes.popup}>
              <Lightbox.Title className="sr-only">Album</Lightbox.Title>
              <Lightbox.Viewport className={classes.viewport}>
                {(photo: Photo) => (
                  <Lightbox.Slide className={classes.slide}>
                    <Lightbox.Media width={photo.width} height={photo.height}>
                      <img src={photo.src} alt={photo.alt} />
                    </Lightbox.Media>
                  </Lightbox.Slide>
                )}
              </Lightbox.Viewport>
              <header className={`${classes.topBar} ${classes.controlTop}`}>
                <Lightbox.Counter className={classes.counter} />
                <div className={local.TopActions}>
                  {/* Removing the open item from `items` is all it takes. */}
                  <button
                    type="button"
                    aria-label="Delete photo"
                    className={classes.iconButton}
                    onClick={remove}
                  >
                    <TrashIcon />
                  </button>
                  <Lightbox.Close className={classes.iconButton}>
                    <CloseIcon />
                  </Lightbox.Close>
                </div>
              </header>
            </Lightbox.Popup>
          </Lightbox.Portal>
        </Lightbox.Root>
      </div>
    </section>
  );
}
```

```tsx title="Controlled lightbox"
const [open, setOpen] = React.useState(false);
const [value, setValue] = React.useState<LightboxItemValue>(photos[0].id);

<Lightbox.Root
  items={photos}
  open={open}
  onOpenChange={setOpen}
  value={value}
  onValueChange={setValue}
>
  …
</Lightbox.Root>;
```

Both callbacks also receive `eventDetails`, built like Base UI's: `reason` tells what made the change (`"swipe"`, `"trigger-press"`, `"thumbnail-press"`, `"escape-key"`, `"close-press"` …), `event` is the native event and `trigger` the element it came from. Call `eventDetails.cancel()` to stop it: a cancelled swipe springs back, and a cancelled pull to dismiss puts the image back. `onOpenChangeComplete` is called once the image has landed, in the viewer (`true`) or back on the page (`false`).

```tsx title="Change reasons"
<Lightbox.Root
  items={photos}
  onOpenChange={(open, eventDetails) => {
    // A press outside the image keeps the viewer open; Escape and Close still close it.
    if (eventDetails.reason === "outside-press") eventDetails.cancel();
  }}
>
  …
</Lightbox.Root>
```

### Loading and errors

Before it opens, the viewer is mounted out of sight until the `<img>` or `<video>` in `Lightbox.Media` has loaded (a video, its size), so the image shows at once and flies at the right size. The trigger has `data-pending` meanwhile. The viewer opens anyway after `LIGHTBOX_OPEN_TIMEOUT` (3 s), still loading.

`onOpenChange` can return a Promise, and the viewer waits for it as well: here the slow photo waits 1.5 seconds more, as on a slow network. It waits up to 3 s before opening and 300 ms before closing (`LIGHTBOX_CLOSE_TIMEOUT`), then goes on. `eventDetails.cancel()` works until the wait ends, after an `await` too, and a cancelled change never happened: no `onOpenChangeComplete`, nothing to undo. A rejected Promise is logged, and the viewer goes on.

Inside the viewer, `Lightbox.Media` and its `Lightbox.Slide` have `data-loading` while the media loads and `data-error` if it fails, from the media's own events. Put a spinner or a message next to the media, inside `Lightbox.Media`: everything in it flies and follows the pull to dismiss. When the image fails, hide it (`group-data-error:invisible`): the box keeps its shape and the message takes its place. Give the `<img>` a new `src` to load it again. A failed image doesn't zoom, and the viewer keeps working.

- **Unknown size.** Without `width` and `height`, the shape is only known once the image loads: fade it in with `group-data-loading:opacity-0`.
- **Retry.** The fourth photo fails once and loads on retry. The message has `role="alert"`, so screen readers announce it.
- **Broken thumbnails.** When a photo is gone, its thumbnail fails too. Base UI's `Avatar` shows an icon in its place; its `delay` keeps a thumbnail that loads quickly from flashing the icon.

```tsx title="loading-and-errors.tsx"
"use client";
import * as React from "react";
import { Avatar } from "@logic-ui/react/avatar";
import { Lightbox, type LightboxItemValue } from "@logic-ui/react/lightbox";
import { BROKEN_SRC, getThumbnailSrc, photos, type Photo } from "./_photos";
import { BrokenImageIcon, CloseIcon } from "./_icons";
import { classes } from "./_classes";

/** Tailwind classes of this demo. */
const local = {
  Gallery:
    "grid w-full max-w-xl grid-cols-[repeat(auto-fill,minmax(6.5rem,1fr))] gap-3 select-none",
  Item: "flex flex-col gap-1.5",
  Tile: "aspect-square rounded-xl",
  Label: "m-0 text-center text-xs text-neutral-500",
  // On the trigger while the viewer waits before it opens.
  Pending:
    "absolute inset-0 m-auto hidden size-6 animate-spin rounded-full border-2 border-white/30 border-t-white group-data-pending:block",
  BrokenTile:
    "flex size-full items-center justify-center bg-white/5 text-neutral-600",
  Media: "group",
  // Fades in once loaded; hidden, but keeping its place, when it fails.
  Image:
    "transition-opacity duration-500 group-data-loading:opacity-0 group-data-error:invisible",
  Error:
    "absolute inset-0 m-auto hidden h-fit w-[min(19rem,calc(100%-2rem))] flex-col items-center gap-2 rounded-2xl border border-white/10 bg-neutral-900/90 px-6 py-6 text-center shadow-2xl backdrop-blur-xl group-data-error:flex",
  ErrorIcon: "size-9 text-neutral-500",
  ErrorTitle: "m-0 text-[0.9375rem] font-semibold",
  ErrorText: "m-0 text-sm text-neutral-400",
  Retry:
    "mt-2 h-9 cursor-pointer rounded-full border-none bg-white px-5 text-sm font-medium text-neutral-950 hover:bg-neutral-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white",
  Caption: `m-0 text-sm text-neutral-300 ${classes.enter}`,
};

type DemoPhoto = Photo & {
  label: string;
  /** How its image behaves on this demo's network. */
  network: "ok" | "slow" | "flaky" | "missing";
};

const items: DemoPhoto[] = [
  { ...photos[0], label: "Instant", network: "ok" },
  { ...photos[1], label: "Slow network", network: "slow" },
  // No `width` or `height`: its shape is only known once it loads.
  {
    ...photos[2],
    width: undefined,
    height: undefined,
    label: "Unknown size",
    network: "ok",
  },
  { ...photos[3], label: "Fails once", network: "flaky" },
  { ...photos[4], label: "Missing", network: "missing" },
];

/** The URL of a photo after `attempts` retries. */
function getSrc(photo: DemoPhoto, attempts: number) {
  if (photo.network === "missing") return BROKEN_SRC;
  if (photo.network === "flaky" && attempts === 0) return BROKEN_SRC;
  return photo.src;
}

/** Waits as long as a slow network would. */
function delay(ms: number) {
  return new Promise<void>((resolve) => {
    setTimeout(resolve, ms);
  });
}

export default function ExampleLightboxLoadingAndErrors() {
  // Retries per photo: a new `src` makes `Lightbox.Media` load again.
  const [attempts, setAttempts] = React.useState<Record<string, number>>({});
  const retry = (id: string) =>
    setAttempts((current) => ({ ...current, [id]: (current[id] ?? 0) + 1 }));

  // The photo the viewer opens on, to know when it is the slow one. A ref:
  // a trigger press reports it just before `onOpenChange`.
  const valueRef = React.useRef<LightboxItemValue>(items[0].id);

  return (
    <Lightbox.Root
      items={items}
      onValueChange={(value) => {
        valueRef.current = value;
      }}
      // The viewer waits for its image to load, and for this Promise: the
      // slow photo takes 1.5 s more, as on a slow network.
      onOpenChange={(open) => {
        const photo = items.find((item) => item.id === valueRef.current);
        if (open && photo?.network === "slow") return delay(1500);
        return undefined;
      }}
    >
      <div className={local.Gallery}>
        {items.map((photo) => (
          <div key={photo.id} className={local.Item}>
            <Lightbox.Trigger
              value={photo.id}
              className={`${classes.trigger} ${local.Tile}`}
            >
              {/* Avatar shows an icon when the thumbnail fails, after a short delay. */}
              <Avatar.Root className="size-full">
                <Avatar.Image
                  src={
                    photo.network === "missing"
                      ? BROKEN_SRC
                      : getThumbnailSrc(photo)
                  }
                  alt=""
                  className={classes.triggerImage}
                />
                <Avatar.Fallback delay={300} className={local.BrokenTile}>
                  <BrokenImageIcon className="size-6" />
                </Avatar.Fallback>
              </Avatar.Root>
              <span className={local.Pending} aria-hidden />
            </Lightbox.Trigger>
            <p className={local.Label}>{photo.label}</p>
          </div>
        ))}
      </div>

      <Lightbox.Portal>
        <Lightbox.Backdrop className={classes.backdrop} />
        <Lightbox.Popup className={classes.popup}>
          <Lightbox.Title className="sr-only">Photos</Lightbox.Title>
          <Lightbox.Viewport className={classes.viewport}>
            {(photo: DemoPhoto) => (
              <Lightbox.Slide className={classes.slide}>
                {/* Everything inside flies and follows the pull to dismiss. */}
                <Lightbox.Media
                  width={photo.width}
                  height={photo.height}
                  className={local.Media}
                >
                  <img
                    src={getSrc(photo, attempts[photo.id] ?? 0)}
                    alt={photo.alt}
                    className={local.Image}
                  />
                  <span className={classes.spinner} aria-hidden />
                  <div className={local.Error} role="alert">
                    <BrokenImageIcon className={local.ErrorIcon} />
                    <p className={local.ErrorTitle}>Couldn’t load this photo</p>
                    <p className={local.ErrorText}>
                      {photo.network === "flaky"
                        ? "The connection dropped. Try again."
                        : "It was moved or deleted."}
                    </p>
                    {photo.network === "flaky" && (
                      <button
                        type="button"
                        className={local.Retry}
                        onClick={() => retry(photo.id)}
                      >
                        Retry
                      </button>
                    )}
                  </div>
                </Lightbox.Media>
              </Lightbox.Slide>
            )}
          </Lightbox.Viewport>
          <header className={`${classes.topBar} ${classes.controlTop}`}>
            <Lightbox.Counter className={classes.counter} />
            <Lightbox.Close className={classes.iconButton}>
              <CloseIcon />
            </Lightbox.Close>
          </header>
          <footer className={`${classes.bottomBar} ${classes.controlBottom}`}>
            <Lightbox.Description className={local.Caption}>
              {(photo: DemoPhoto) => photo.label}
            </Lightbox.Description>
          </footer>
        </Lightbox.Popup>
      </Lightbox.Portal>
    </Lightbox.Root>
  );
}
```

```tsx title="Loading and errors"
<Lightbox.Media width={photo.width} height={photo.height} className="group">
  <img src={photo.src} alt={photo.alt} className="group-data-error:invisible" />
  <span className="hidden group-data-loading:block">{/* spinner */}</span>
  <p className="hidden group-data-error:block">
    This image couldn’t be loaded.
  </p>
</Lightbox.Media>
```

`Lightbox.Media` only reads its direct `<img>`, `<video>` or `<iframe>` child (or the `<img>` of a `<picture>`), so an icon inside the message doesn't count. Content without one counts as loaded at once.

To show smaller images as thumbnails and larger ones in the viewer, give each `<img>` its own `src` or `srcSet`: the viewer waits for the one it shows.

### Video and other media

`Lightbox.Media` takes any media as its direct child: an `<img>`, a `<video>`, an `<iframe>`. It fits the media's shape inside the slide, flies it from the trigger and back, and follows the pull to dismiss. Only images zoom: on other media the zoom gestures do nothing and `Lightbox.ZoomIn` is disabled. A video pauses when its slide is no longer shown and when the viewer closes, and while a player with `controls` has focus, the arrow keys stay its own. Give the `<video>` `preload="metadata"`: the viewer opens once it knows the video's size.

```tsx title="video.tsx"
"use client";
import * as React from "react";
import { Lightbox } from "@logic-ui/react/lightbox";
import { Toolbar } from "@logic-ui/react/toolbar";
import { getThumbnailSrc, type MediaItem, photos, videos } from "./_photos";
import { CloseIcon, PlayIcon, ZoomInIcon, ZoomOutIcon } from "./_icons";
import { classes } from "./_classes";

/** Tailwind classes of this demo. */
const local = {
  Row: "grid w-full max-w-md grid-cols-3 gap-2 select-none",
  Tile: "aspect-[3/4] rounded-xl",
  Badge:
    "absolute bottom-2 left-2 flex items-center gap-1 rounded-full bg-black/60 px-2 py-1 text-[0.6875rem] font-medium text-white backdrop-blur-md",
  Media: "group",
  Video: "rounded-lg bg-black",
  Caption: `m-0 text-sm text-neutral-300 ${classes.enter}`,
};

type Item = MediaItem;

const items: Item[] = [
  { ...photos[5], type: "image" },
  videos[0],
  { ...photos[1], type: "image" },
];

/**
 * Photos and a video in one viewer. The video pauses when you swipe away
 * or close; the zoom buttons are disabled on it, as only images zoom.
 */
export default function ExampleLightboxVideo() {
  return (
    <Lightbox.Root items={items}>
      <div className={local.Row}>
        {items.map((item) => (
          <Lightbox.Trigger
            key={item.id}
            value={item.id}
            aria-label={`Open ${item.alt}`}
            className={`${classes.trigger} ${local.Tile}`}
          >
            {item.type === "video" ? (
              <React.Fragment>
                {/* A frame of the video as its thumbnail. */}
                <video
                  src={`${item.src}#t=1`}
                  muted
                  playsInline
                  preload="metadata"
                  className={classes.triggerImage}
                />
                <span className={local.Badge}>
                  <PlayIcon className="size-3" /> Video
                </span>
              </React.Fragment>
            ) : (
              <img
                loading="lazy"
                src={getThumbnailSrc(item)}
                alt=""
                className={classes.triggerImage}
              />
            )}
          </Lightbox.Trigger>
        ))}
      </div>

      <Lightbox.Portal>
        <Lightbox.Backdrop className={classes.backdrop} />
        <Lightbox.Popup className={classes.popup}>
          <Lightbox.Title className="sr-only">Photos and videos</Lightbox.Title>
          <Lightbox.Viewport className={classes.viewport}>
            {(item: Item) => (
              <Lightbox.Slide className={classes.slide}>
                <Lightbox.Media
                  width={item.width}
                  height={item.height}
                  className={local.Media}
                >
                  {item.type === "video" ? (
                    <video
                      src={item.src}
                      controls
                      playsInline
                      preload="metadata"
                      className={local.Video}
                    />
                  ) : (
                    <img src={item.src} alt={item.alt} />
                  )}
                  <span className={classes.spinner} aria-hidden />
                </Lightbox.Media>
              </Lightbox.Slide>
            )}
          </Lightbox.Viewport>
          <header className={`${classes.topBar} ${classes.controlTop}`}>
            <Lightbox.Counter className={classes.counter} />
            <Lightbox.Close className={classes.iconButton}>
              <CloseIcon />
            </Lightbox.Close>
          </header>
          <footer className={`${classes.bottomBar} ${classes.controlBottom}`}>
            <Lightbox.Description className={local.Caption}>
              {(item: Item) => item.alt}
            </Lightbox.Description>
            <Toolbar.Root className={classes.toolbar}>
              <Lightbox.ZoomOut
                render={<Toolbar.Button />}
                className={classes.toolbarButton}
              >
                <ZoomOutIcon />
              </Lightbox.ZoomOut>
              <Lightbox.ZoomValue className={classes.toolbarValue} />
              <Lightbox.ZoomIn
                render={<Toolbar.Button />}
                className={classes.toolbarButton}
              >
                <ZoomInIcon />
              </Lightbox.ZoomIn>
            </Toolbar.Root>
          </footer>
        </Lightbox.Popup>
      </Lightbox.Portal>
    </Lightbox.Root>
  );
}
```

Give an embed its `width` and `height` (`16` and `9`). An iframe keeps every touch to itself, so swipe beside it or use `Lightbox.Previous` and `Lightbox.Next`.

### Captions and animations

`Lightbox.Title` and `Lightbox.Description` name and describe the viewer for screen readers. A function child receives the item shown, to build its title or caption; `Lightbox.Description` renders nothing when the result is empty. For text that belongs to a slide and moves with it, put a `Lightbox.Caption` in the slide: it describes its slide.

When the item shown changes, `Lightbox.Title` and `Lightbox.Description` (with a function child), `Lightbox.Counter` and `Lightbox.Caption` have `data-starting-style` for one frame, with `data-activation-direction` set to the side the new item came from: `right` after Next in a left-to-right layout, `left` after Previous. Style where the new text starts from, and a transition brings it in, as in the gallery at the top of the page. The element stays on screen while its text changes, so turn the transition off while the attribute is there (`data-starting-style:transition-none`): the text jumps to its start, then animates in.

```tsx title="Animated captions"
<Lightbox.Description className="transition-[opacity,translate] duration-300 data-starting-style:opacity-0 data-starting-style:transition-none data-starting-style:data-[activation-direction=right]:translate-x-5 data-starting-style:data-[activation-direction=left]:-translate-x-5">
  {(photo) => photo.caption}
</Lightbox.Description>
```

`Lightbox.Counter` and `Lightbox.ZoomValue` take a function child, which receives their state, to format the position and the scale: ``{(state) => `${state.index + 1} of ${state.count}`}``.

### Toolbar

The actions are plain buttons. To group them in a Base UI [Toolbar](/docs/components/toolbar), render each one as a `Toolbar.Button`. The lightbox part goes outside, so it keeps its `disabled`, and the toolbar keeps a disabled action focusable for its arrow keys. Set `orientation="vertical"` on `Toolbar.Root` for a column: ↑ and ↓ move between the actions.

```tsx title="Toolbar"
import { Toolbar } from "@logic-ui/react/toolbar";

<Toolbar.Root>
  <Lightbox.Previous render={<Toolbar.Button />} />
  <Lightbox.Counter />
  <Lightbox.Next render={<Toolbar.Button />} />
  <Toolbar.Separator />
  <Lightbox.ZoomOut render={<Toolbar.Button />} />
  <Lightbox.ZoomIn render={<Toolbar.Button />} />
</Toolbar.Root>;
```

### Thumbnails

`Lightbox.ThumbnailList` renders a `Lightbox.Thumbnail` per item inside the viewer, through a function child like `Lightbox.Viewport`. What's inside a thumbnail is yours: an image, a dot, a title. The list is a tab list and each thumbnail a tab, as in the WAI-ARIA tabbed carousel: the slides become their tab panels, and the thumbnail of the item shown has `aria-selected` and `data-active`.

- **Keyboard.** The list is a single tab stop. The arrow keys move between thumbnails and show their item, and Home and End go to the first and last. With `activateOnFocus={false}`, the arrows only move focus and Enter shows the item.
- **Orientation.** `orientation` sets which arrow keys move and which way the list scrolls: ← → for `"horizontal"` (the default), ↑ ↓ for `"vertical"`. The list has `data-orientation`, so one class list can style both.
- **Scrolling.** Give the list `overflow: auto`. The active thumbnail glides to its centre, and is centred again when a CSS transition changes its size. The mouse can drag the list (`data-swiping` meanwhile), and the wheel scrolls a horizontal list. Touch scrolls natively.
- **Edges.** The list has `data-overflow-start` and `data-overflow-end` while thumbnails are hidden past an edge: fade that edge with a mask, `data-overflow-end:mask-r-from-85%`. Hide the browser's scrollbar with `[scrollbar-width:none] [&::-webkit-scrollbar]:hidden`.
- **Sliding indicator.** `Lightbox.ThumbnailIndicator`, placed in the list after the function child, is one element that glides to the active thumbnail, like Base UI's `Tabs.Indicator`. Make the list `relative`: the indicator gets the active thumbnail's box in it as `--active-thumbnail-left`, `--active-thumbnail-top`, `--active-thumbnail-width` and `--active-thumbnail-height`, scrolls with the list, and follows thumbnails that change size. The variables move with the slides on every frame: towards the next or previous thumbnail while a swipe drags them, then along with them as they settle, at the speed of the flick, and the same for Previous, Next and the keys. So the indicator needs no transition: without one, it lands exactly when the slides do. A jump of several items (Home, End, a far thumbnail) starts the glide from the thumbnail next to the target.

```tsx title="Thumbnails"
<Lightbox.ThumbnailList className="relative flex gap-2 overflow-x-auto">
  {(photo) => (
    <Lightbox.Thumbnail aria-label={photo.alt}>
      <img src={photo.thumb} alt="" />
    </Lightbox.Thumbnail>
  )}
  <Lightbox.ThumbnailIndicator className="absolute top-(--active-thumbnail-top) left-(--active-thumbnail-left) h-(--active-thumbnail-height) w-(--active-thumbnail-width)" />
</Lightbox.ThumbnailList>
```

### Portfolio

A list with a title per project beside the image on wide screens, and a strip of squares below it on phones: `orientation` follows a media query so the arrow keys match the layout, and the classes switch on `data-orientation`. The indicator is a soft highlight behind the active row, or a ring around the active square. A vertical toolbar zooms; the panorama goes to 800% with `maxScale={8}` (4 by default).

Switch to right to left: the next image is on the left, and the arrow keys and swipes follow. The viewer is portaled to the end of `<body>`, so it takes its own `dir`, and Base UI's `DirectionProvider` sets the direction of the lightbox parts.

```tsx title="portfolio.tsx"
"use client";
import * as React from "react";
import { DirectionProvider } from "@logic-ui/react/direction-provider";
import { Lightbox } from "@logic-ui/react/lightbox";
import { Toolbar } from "@logic-ui/react/toolbar";
import { useMediaQuery } from "@logic-ui/react/unstable-use-media-query";
import { getThumbnailSrc, photos, type Photo } from "./_photos";
import { CloseIcon, ResetIcon, ZoomInIcon, ZoomOutIcon } from "./_icons";
import { classes } from "./_classes";

/** Tailwind classes of this demo. */
const local = {
  Page: "flex w-full max-w-xl flex-col gap-4 select-none",
  PageHeader: "flex items-center justify-between gap-3",
  PageTitle: "m-0 text-base font-semibold",
  Switch:
    "flex rounded-lg border border-white/10 bg-white/5 p-0.5 text-xs [&_button]:h-7 [&_button]:cursor-pointer [&_button]:rounded-md [&_button]:border-none [&_button]:bg-transparent [&_button]:px-2.5 [&_button]:text-neutral-400 [&_button[aria-pressed=true]]:bg-white/12 [&_button[aria-pressed=true]]:text-white",
  Cards: "grid grid-cols-2 gap-3 sm:grid-cols-3",
  Card: "flex flex-col gap-2 text-start",
  CardImage: "aspect-[4/3] w-full rounded-xl",
  CardTitle: "m-0 text-sm font-medium",
  CardText: "m-0 truncate text-xs text-neutral-500",
  // The image leaves room for the list: beside it on wide screens (at the
  // end, so on the left in right-to-left), below it on phones. It clips the
  // next image and the zoomed one, which would show through the list.
  Viewport:
    "absolute inset-x-0 top-0 bottom-24 cursor-default gap-x-4 overflow-hidden select-none in-data-zoomed:cursor-grab in-data-zoomed:data-swiping:cursor-grabbing md:end-72 md:bottom-0",
  Slide: "px-4 pt-[calc(4rem+env(safe-area-inset-top))] pb-4 md:px-20 md:py-16",
  Close: "absolute top-[calc(0.75rem+env(safe-area-inset-top))] start-3",
  // On wide screens, a list with titles; on phones, a strip of squares.
  // Styled through `data-orientation`, so one class list fits both.
  Panel:
    "absolute inset-x-0 bottom-0 h-24 border-t border-white/10 bg-neutral-950/80 backdrop-blur-xl md:inset-x-auto md:inset-y-0 md:end-0 md:flex md:h-auto md:w-72 md:flex-col md:border-t-0 md:border-s",
  PanelHeading:
    "m-0 hidden px-4 pt-5 pb-3 text-xs font-semibold tracking-wide text-neutral-500 uppercase md:block",
  List: `relative isolate flex gap-2 overflow-auto data-[orientation=horizontal]:h-full data-[orientation=horizontal]:items-center data-[orientation=horizontal]:px-[50%] data-[orientation=vertical]:flex-1 data-[orientation=vertical]:flex-col data-[orientation=vertical]:px-2 data-[orientation=vertical]:pb-4 ${classes.hideScrollbar} data-[orientation=horizontal]:data-overflow-start:mask-l-from-85% data-[orientation=horizontal]:data-overflow-end:mask-r-from-85% data-[orientation=vertical]:data-overflow-start:mask-t-from-85% data-[orientation=vertical]:data-overflow-end:mask-b-from-85%`,
  Thumbnail:
    "flex flex-none cursor-pointer items-center gap-3 rounded-lg border-none bg-transparent p-0 text-start text-sm text-white opacity-60 transition-opacity duration-200 hover:opacity-100 data-active:opacity-100 in-data-[orientation=vertical]:p-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white",
  ThumbnailImage:
    "size-14 flex-none rounded-md object-cover in-data-[orientation=vertical]:size-12",
  ThumbnailText:
    "hidden min-w-0 flex-col in-data-[orientation=vertical]:flex [&_span]:truncate [&_span+span]:text-xs [&_span+span]:text-neutral-500",
  // A soft highlight behind the active row, or a ring around the active square.
  Highlight: `${classes.indicator} -z-10 rounded-lg in-data-[orientation=vertical]:bg-white/10 in-data-[orientation=vertical]:outline-none`,
  // Zoom, on the image's edge on wide screens: ↑ ↓ move between the actions.
  // On phones, across from Close, where it doesn't cover the image: ← →.
  ZoomBar:
    "absolute top-[calc(0.75rem+env(safe-area-inset-top))] end-3 md:top-1/2 md:end-[calc(18rem+0.75rem)] md:-translate-y-1/2 md:flex-col",
  ZoomValue:
    "px-1 text-center text-[0.6875rem] text-neutral-400 tabular-nums md:px-0 md:py-1",
  ZoomSeparator: "mx-1 h-4 w-px bg-white/15 md:mx-0 md:my-1 md:h-px md:w-4",
};

/** Titles for the portfolio. The panorama zooms to 800%. */
const projects: Photo[] = photos.map((photo) => ({
  ...photo,
  caption: photo.caption ?? "Untitled",
}));

export default function ExampleLightboxPortfolio() {
  const [direction, setDirection] = React.useState<"ltr" | "rtl">("ltr");
  // The orientation sets the arrow keys, so it follows the layout.
  const wide = useMediaQuery("(min-width: 48rem)", {});

  return (
    <DirectionProvider direction={direction}>
      <div dir={direction} className={local.Page}>
        <header className={local.PageHeader}>
          <h3 className={local.PageTitle}>Selected work</h3>
          <div className={local.Switch} role="group" aria-label="Direction">
            <button
              type="button"
              aria-pressed={direction === "ltr"}
              onClick={() => setDirection("ltr")}
            >
              LTR
            </button>
            <button
              type="button"
              aria-pressed={direction === "rtl"}
              onClick={() => setDirection("rtl")}
            >
              RTL
            </button>
          </div>
        </header>

        <Lightbox.Root items={projects} maxScale={8}>
          <div className={local.Cards}>
            {projects.slice(0, 6).map((photo) => (
              <div key={photo.id} className={local.Card}>
                <Lightbox.Trigger
                  value={photo.id}
                  className={`${classes.trigger} ${local.CardImage}`}
                >
                  <img
                    loading="lazy"
                    src={getThumbnailSrc(photo)}
                    alt=""
                    className={classes.triggerImage}
                  />
                </Lightbox.Trigger>
                <div>
                  <p className={local.CardTitle}>{photo.caption}</p>
                  <p className={local.CardText}>{photo.alt}</p>
                </div>
              </div>
            ))}
          </div>

          <Lightbox.Portal>
            <Lightbox.Backdrop className={classes.backdrop} />
            {/* Portaled to the end of `<body>`: it needs its own `dir`. */}
            <Lightbox.Popup dir={direction} className={classes.popup}>
              <Lightbox.Title className="sr-only">Selected work</Lightbox.Title>
              <Lightbox.Viewport className={local.Viewport}>
                {(photo: Photo) => (
                  <Lightbox.Slide className={local.Slide}>
                    <Lightbox.Media width={photo.width} height={photo.height}>
                      <img src={photo.src} alt={photo.alt} />
                    </Lightbox.Media>
                  </Lightbox.Slide>
                )}
              </Lightbox.Viewport>

              <Lightbox.Close
                className={`${classes.iconButton} ${local.Close} ${classes.controlTop}`}
              >
                <CloseIcon />
              </Lightbox.Close>

              <Toolbar.Root
                orientation={wide ? "vertical" : "horizontal"}
                className={`${classes.toolbar} ${local.ZoomBar} ${wide ? classes.controlRight : classes.controlTop}`}
              >
                <Lightbox.ZoomIn
                  render={<Toolbar.Button />}
                  className={classes.toolbarButton}
                >
                  <ZoomInIcon />
                </Lightbox.ZoomIn>
                {/* Short enough for a narrow toolbar: 1.0x, 7.5x. */}
                <Lightbox.ZoomValue className={local.ZoomValue}>
                  {(state) => `${state.scale.toFixed(1)}x`}
                </Lightbox.ZoomValue>
                <Lightbox.ZoomOut
                  render={<Toolbar.Button />}
                  className={classes.toolbarButton}
                >
                  <ZoomOutIcon />
                </Lightbox.ZoomOut>
                <Toolbar.Separator className={local.ZoomSeparator} />
                <Lightbox.ZoomReset
                  render={<Toolbar.Button />}
                  className={classes.toolbarButton}
                >
                  <ResetIcon />
                </Lightbox.ZoomReset>
              </Toolbar.Root>

              <aside
                className={`${local.Panel} ${wide ? classes.controlRight : classes.controlBottom}`}
              >
                <h4 className={local.PanelHeading}>
                  <Lightbox.Counter>
                    {(state) => `Project ${state.index + 1} of ${state.count}`}
                  </Lightbox.Counter>
                </h4>
                <Lightbox.ThumbnailList
                  orientation={wide ? "vertical" : "horizontal"}
                  aria-label="All projects"
                  className={local.List}
                >
                  {(photo: Photo) => (
                    // The text names the thumbnail in the list; on phones, `aria-label` does.
                    <Lightbox.Thumbnail
                      aria-label={wide ? undefined : photo.caption}
                      className={local.Thumbnail}
                    >
                      <img
                        loading="lazy"
                        src={getThumbnailSrc(photo)}
                        alt=""
                        className={local.ThumbnailImage}
                      />
                      <span className={local.ThumbnailText}>
                        <span>{photo.caption}</span>
                        <span>{photo.alt}</span>
                      </span>
                    </Lightbox.Thumbnail>
                  )}
                  <Lightbox.ThumbnailIndicator className={local.Highlight} />
                </Lightbox.ThumbnailList>
              </aside>
            </Lightbox.Popup>
          </Lightbox.Portal>
        </Lightbox.Root>
      </div>
    </DirectionProvider>
  );
}
```

### Cropping in the viewer

To crop the photo shown, lay a [Cropper](/docs/components/cropper#in-a-lightbox) over the viewer inside `Lightbox.Popup`: see its photo editor demo.

### Phone viewer

The viewer works like the photo app of a phone:

- **Swipe** between images; the image follows the finger, carries on with the flick, and rubber-bands at the first and last image.
- **Pinch** to zoom around the fingers, and **double tap** to zoom in at the tapped point or back out.
- **Pan** a zoomed image; past its edge, the drag carries on to the next image.
- **Pull down** (or up) to dismiss: the image follows the finger and flies back to its thumbnail on release.
- **Tap** to hide or show the controls.

With a mouse or trackpad, drag swipes, double click zooms, and the wheel or a trackpad pinch zooms around the cursor.

Here the image fills the screen width under translucent bars. While the image is pulled to dismiss, `--lightbox-dismiss-progress` goes from 0 to 1 on the popup and the backdrop: the page fades in and un-blurs behind it, and the bars slide off the top and bottom of the screen. Try it on a phone, or with the browser's device toolbar.

```tsx title="phone.tsx"
"use client";
import * as React from "react";
import { Lightbox } from "@logic-ui/react/lightbox";
import { getThumbnailSrc, photos, type Photo } from "./_photos";
import { ChevronIcon } from "./_icons";
import { classes } from "./_classes";

/** Tailwind classes of this demo. */
const local = {
  Roll: "w-full max-w-sm overflow-hidden rounded-3xl border border-white/10 bg-black select-none",
  RollHeader: "flex items-baseline justify-between px-4 pt-4 pb-3",
  RollTitle: "m-0 text-lg font-bold",
  RollCount: "text-xs text-neutral-500",
  // Square tiles edge to edge, like a phone's camera roll.
  RollGrid: "grid grid-cols-4 gap-0.5",
  Tile: "aspect-square",
  // The image fills the screen width, behind the bars.
  Viewport:
    "absolute inset-0 cursor-default gap-x-2 select-none in-data-zoomed:cursor-grab in-data-zoomed:data-swiping:cursor-grabbing",
  Slide: "p-0",
  TopBar:
    "absolute inset-x-0 top-0 grid grid-cols-[2.5rem_1fr_2.5rem] items-center gap-2 bg-black/50 px-2 pt-[calc(0.5rem+env(safe-area-inset-top))] pb-2 backdrop-blur-xl",
  Back: "flex size-10 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-white focus-visible:outline-2 focus-visible:outline-white",
  Heading: "flex min-w-0 flex-col items-center text-center",
  Title: `m-0 truncate text-[0.9375rem] font-semibold ${classes.enter}`,
  Counter: "text-xs text-neutral-400 tabular-nums",
  BottomBar:
    "absolute inset-x-0 bottom-0 flex flex-col items-center gap-2 bg-black/50 px-3 pt-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] backdrop-blur-xl",
  Caption: `m-0 text-[0.8125rem] text-neutral-300 ${classes.enter}`,
  Strip: `relative flex max-w-full gap-1 overflow-x-auto px-[50%] py-1 ${classes.hideScrollbar} ${classes.fadeX}`,
  Thumbnail:
    "h-10 w-7 flex-none cursor-pointer overflow-hidden rounded-sm border-none bg-white/10 p-0 transition-[width] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] data-active:w-12 focus-visible:outline-2 focus-visible:outline-white",
  Ring: `${classes.indicator} rounded-sm outline-offset-1`,
};

/**
 * Like a phone's photo app: the image fills the screen width, translucent bars
 * sit over it, and a tap hides them. Drag the image down: the bars slide away
 * and the page shows through. Try it on a phone, or the browser's device mode.
 */
export default function ExampleLightboxPhone() {
  return (
    <Lightbox.Root items={photos}>
      <section className={local.Roll}>
        <header className={local.RollHeader}>
          <h3 className={local.RollTitle}>Recents</h3>
          <span className={local.RollCount}>{photos.length} photos</span>
        </header>
        <div className={local.RollGrid}>
          {photos.map((photo) => (
            <Lightbox.Trigger
              key={photo.id}
              value={photo.id}
              className={`${classes.trigger} ${local.Tile}`}
            >
              <img
                loading="lazy"
                src={getThumbnailSrc(photo)}
                alt=""
                className={classes.triggerImage}
              />
            </Lightbox.Trigger>
          ))}
        </div>
      </section>

      <Lightbox.Portal>
        <Lightbox.Backdrop className={classes.backdrop} />
        <Lightbox.Popup className={classes.popup}>
          <Lightbox.Viewport className={local.Viewport}>
            {(photo: Photo) => (
              <Lightbox.Slide className={local.Slide}>
                <Lightbox.Media width={photo.width} height={photo.height}>
                  <img src={photo.src} alt={photo.alt} />
                </Lightbox.Media>
              </Lightbox.Slide>
            )}
          </Lightbox.Viewport>

          <header className={`${local.TopBar} ${classes.controlTop}`}>
            <Lightbox.Close aria-label="Back" className={local.Back}>
              <ChevronIcon direction="left" />
            </Lightbox.Close>
            <div className={local.Heading}>
              <Lightbox.Title className={local.Title}>
                {(photo: Photo) => photo.caption ?? "Untitled"}
              </Lightbox.Title>
              <Lightbox.Counter className={local.Counter}>
                {(state) => `${state.index + 1} of ${state.count}`}
              </Lightbox.Counter>
            </div>
          </header>

          <footer className={`${local.BottomBar} ${classes.controlBottom}`}>
            <Lightbox.Description className={local.Caption}>
              {(photo: Photo) => photo.alt}
            </Lightbox.Description>
            {/* The active thumbnail widens, and the ring follows it. */}
            <Lightbox.ThumbnailList className={local.Strip}>
              {(photo: Photo) => (
                <Lightbox.Thumbnail
                  aria-label={photo.alt}
                  className={local.Thumbnail}
                >
                  <img
                    loading="lazy"
                    src={getThumbnailSrc(photo)}
                    alt=""
                    className="size-full object-cover"
                  />
                </Lightbox.Thumbnail>
              )}
              <Lightbox.ThumbnailIndicator className={local.Ring} />
            </Lightbox.ThumbnailList>
          </footer>
        </Lightbox.Popup>
      </Lightbox.Portal>
    </Lightbox.Root>
  );
}
```

```tsx title="Controls that slide away"
// The top bar slides up and out, faster than the drag goes on; no transition while swiping.
<header className="-translate-y-[calc((100%+1rem)*1.5*var(--lightbox-dismiss-progress,0))] transition-[translate] in-data-swiping:transition-none">
  …
</header>
```

### Styling

Every part is unstyled. Style the viewer with Tailwind (or any CSS), using the parts' data attributes and CSS variables:

- `Lightbox.Popup` has `data-zoomed` while the image is zoomed, `data-controls-hidden` after a tap hid the controls, and `data-starting-style` / `data-ending-style` while the image flies in and out. Fade the controls, not the popup: the image is inside it. From a control, read them with `in-data-*`: `in-data-controls-hidden:opacity-0`. Its `className`, `style` and `render` functions receive `zoomed`, `controlsHidden` and `activationDirection` with the dialog's state.
- `Lightbox.Popup`, `Lightbox.Viewport`, `Lightbox.Slide` and the parts that show the item have `data-activation-direction` (`left`, `right`, or `none` when the viewer opens): the side the item shown came from, mirrored in a right-to-left layout.
- `Lightbox.Popup` gets `--lightbox-swipe-movement-x` (px on screen, negative to the left) and `--lightbox-swipe-progress` (the same as a share of a slide, `-1` to `1`) while a swipe drags the slides and while they settle, like Base UI's drawer swipe variables. They are `0` at rest. Move anything with the swipe: `translate-x-[calc(var(--lightbox-swipe-progress,0)*2rem)]`. They are relative to the item shown, so they jump when it changes at the end of a swipe.
- `Lightbox.Popup` gets `--lightbox-swipe-strength` when a swipe is released: `0.1` for a hard flick to `1` for a slow release, like Base UI's drawer. The slides settle in 300 ms times it, and go back to `1` once settled: scale a transition that follows them, `duration-[calc(300ms*var(--lightbox-swipe-strength,1))]`.
- `Lightbox.Popup` gets `--lightbox-zoom-scale` (`1` at rest) and `--lightbox-zoom-progress` (`0` at rest, `1` at the largest scale), to fade or move things as the image zooms.
- `Lightbox.Popup` and `Lightbox.Backdrop` get `--lightbox-flight-duration` and `--lightbox-flight-easing`: the length and curve of the flight the image is on. After a pull to dismiss, the curve is a spring (a CSS `linear()`) that starts at the speed of the finger. Time the backdrop and the controls with them, `duration-(--lightbox-flight-duration) ease-(--lightbox-flight-easing)`, and they arrive with the image.
- `Lightbox.Popup` gets `--lightbox-flight-movement-x` and `--lightbox-flight-movement-y` (px) and `--lightbox-flight-scale` at the start of each flight: how far from where it rests in the viewer the image flies, to the trigger it lands on when the viewer closes, or from the trigger it leaves when it opens, and its size there as a share of its size in the viewer. They are `0px` and `1` before any flight. Send other content to the same place: `[translate:var(--lightbox-flight-movement-x)_var(--lightbox-flight-movement-y)] scale-(--lightbox-flight-scale)` on `data-ending-style`, timed with `--lightbox-flight-duration`, as the coverflow does.
- `Lightbox.Popup` and `Lightbox.Backdrop` get `--lightbox-dismiss-movement-x` and `--lightbox-dismiss-movement-y` (px) with `--lightbox-dismiss-progress` (0 to 1) while the image is pulled to dismiss, and have `data-swiping` during a drag and until the image settles back. Turn transitions off then (`data-swiping:transition-none`, or `in-data-swiping:transition-none` on a control), or whatever follows the progress lags behind the finger.
- `Lightbox.Viewport` has `data-swiping` during a gesture. Give it a size (`absolute inset-0`) and set the space between images with `gap-x-4`. Nothing clips: the next images, the zoomed image and the image pulled to dismiss are drawn past its edges, off screen in a full-screen viewer. In a smaller one, add `overflow-hidden`; leave it off for the media to leave the viewport, like the stories. One slide of finger travel is its width plus that gap; when the CSS draws the slides closer together, set `--lightbox-swipe-distance` on it (any CSS length) so they keep up with the finger, as the coverflow does.
- `Lightbox.Slide` has `--lightbox-slide-progress`: its distance from the centre in slides, on every frame the slides move. It is placed with `translate`, leaving `transform` free for a custom transition (a cube, a fade, a stack); put a `perspective` on `Lightbox.Viewport` for 3D.
- `Lightbox.Slide`'s padding is the space around the media: `p-0` for edge to edge on phones, `md:px-24` on larger screens. Add `pt-[env(safe-area-inset-top)]` and the like for notches (with `viewport-fit=cover` on the page). It has `data-active`, and `data-loading` and `data-error` for its media.
- `Lightbox.Media` sets its own position and size, and the size of the media in it: style what's around the media, not its box.
- `Lightbox.Trigger` has `data-popup-open` while the image will fly back to it, from the opening flight to the end of the closing one: the trigger of the item shown, or the one `closeTarget` picks. Hide it with `data-popup-open:invisible`: the thumbnail disappears from the page but keeps its place, and comes back when the image lands. It also has `data-pending` while the viewer waits to open from it, and `data-flying` while the image flies from it or to it.

### Keyboard

| Key        | Action                                       |
| :--------- | :------------------------------------------- |
| ← →        | Previous / next image, or pan while zoomed   |
| ↑ ↓        | Pan while zoomed                             |
| Home / End | First / last image                           |
| + −        | Zoom in / out                                |
| 0          | Reset the zoom                               |
| Esc        | Close; the image flies back to its thumbnail |

Inside a widget that uses the arrow keys itself, they stay that widget's: a Base UI `Toolbar`, `Lightbox.ThumbnailList`, a slider, a text field, a video player.

## API reference

### Lightbox.Root

Holds the lightbox state: the items, the item shown, whether the viewer is
open, its animation, and the zoom of the active image. Place the viewer
parts inside it, and the triggers inside it or anywhere else with a
`handle`. Before the viewer opens, it is mounted out of sight until its
`Lightbox.Media` has loaded, so the image flies from its trigger at the
right size; the trigger has `data-pending` meanwhile.
Renders no element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| items (required) | `readonly Item[]` | - | The items, in order. They can have any shape. |
| itemToValue | `(item: Item) => LightboxItemValue` | `(item) => item.id` | Returns the value that identifies an item. It must stay the same when items are added, removed or reordered. |
| children | `React.ReactNode` | - | - |
| open | `boolean` | - | Whether the viewer is open. |
| defaultOpen | `boolean` | `false` | Whether the viewer is open at first, when `open` is not controlled. |
| onOpenChange | `( open: boolean, eventDetails: LightboxRootChangeEventDetails, ) => void \| Promise<void>` | - | Called when the viewer is about to open or close, with why (`eventDetails.reason`). Call `eventDetails.cancel()` to keep it as it is: a cancelled pull to dismiss springs back. Return a Promise to make the viewer wait for it: up to `LIGHTBOX_OPEN_TIMEOUT` (3 s) before it opens, `LIGHTBOX_CLOSE_TIMEOUT` (300 ms) before it closes; it goes on after that. `cancel()` works until the wait ends, after an `await` too. A rejected Promise is logged, and the viewer goes on. |
| onOpenChangeComplete | `(open: boolean) => void` | - | Called when the viewer has finished opening or closing: the image has landed in the viewer, or back on the page. |
| value | `LightboxItemValue` | - | Value of the item shown in the viewer. It stays on the last item shown after the viewer closes. |
| defaultValue | `LightboxItemValue` | `the first item's value` | Value of the item shown at first, when `value` is not controlled. |
| onValueChange | `( value: LightboxItemValue, eventDetails: LightboxRootChangeEventDetails, ) => void` | - | Called when the viewer is about to show another item, with why (`eventDetails.reason`), and before it opens on an item that isn't the one shown. Call `eventDetails.cancel()` to keep the current item: a cancelled swipe springs back. |
| closeTarget | `"active" \| "opener" \| "nearest"` | `"active"` | Where the image flies back to when the viewer closes, and where focus returns. When that trigger shows another image, the image turns into it on the way, and the trigger stays empty while the viewer is open. `"active"`: The trigger of the item shown. Without one on the page, the image fades out. For a grid with a trigger per item. `"opener"`: The trigger that opened the viewer, whichever item is shown. For one cover that opens a whole album. `"nearest"`: The trigger of the item shown, or else the trigger of the nearest item that has one. For a grid that shows a few items and a "+5" tile. |
| scrollToTrigger | `boolean` | `true` | Scrolls the page, hidden behind the viewer, to keep the trigger the image flies back to in view as the items change. Without it, closing on an item whose trigger is off screen fades the image out instead. |
| handle | `LightboxHandle` | - | Links `Lightbox.Trigger`s placed outside the root, from `Lightbox.createHandle()`. |
| maxScale | `number` | `4` | Largest zoom scale. |

### Lightbox.Trigger

Opens the viewer on one item. The image flies from the trigger to the
viewer and back. Put the thumbnail inside it, or lay it over the thumbnail.
It has `data-popup-open` while the image will fly back to it (the item
shown is its own, or `closeTarget` picks it): hide it then, keeping its
space, so the image always lands in an empty slot. When `value` matches no item, only the children render,
without the button. Renders a `<button>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| value (required) | `LightboxItemValue` | - | Value of the item it opens, as returned by `itemToValue`. |
| handle | `LightboxHandle` | - | Links a trigger placed outside `Lightbox.Root` to it, from `Lightbox.createHandle()`. |
| crossfade | `boolean` | `false` | Whether it shows something other than the media it opens: an avatar, a cover, an icon. The image then crossfades with a copy of it during the flights, growing out of its look and turning back into it on the way home. A trigger `closeTarget` picks for another item always crossfades. |
| className | `string` | - | CSS class applied to the element. |
| style | `React.CSSProperties` | - | Style applied to the element. |
| render | `React.ReactElement \| ((props, state) => React.ReactElement)` | - | Replaces the element with another tag or component, or composes it with one. Takes an element, or a function that receives the props and the state. |

| Data attribute | Description |
| --- | --- |
| `data-pending` | Present while its image loads before the viewer opens. |
| `data-flying` | Present while the image flies from it or back to it, even another item's image that `closeTarget` sends here. Hide the trigger meanwhile: the image lands in an empty slot. |
| `data-crossfade` | Present with `crossfade`: it shows something other than the media it opens, and the image crossfades with a copy of it during the flights. |
| `data-disabled` | Present while another trigger's image loads. |
| `data-popup-open` | Present on the trigger the image will fly back to: the one of the item shown, or the one `closeTarget` picks, such as a "+5" tile. From the start of the opening flight to the end of the closing one. Hide the thumbnail meanwhile and keep its space (`visibility: hidden`), like a phone's photo app: the image always lands in an empty slot. |

### Lightbox.Portal

Moves the viewer to the end of `<body>` while it is open.
Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| keepMounted | `boolean` | `false` | Whether to keep the portal mounted in the DOM while the popup is hidden. |
| container | `HTMLElement \| ShadowRoot \| React.RefObject<HTMLElement \| ShadowRoot \| null> \| null` | - | A parent element to render the portal element into. |
| className | `string` | - | CSS class applied to the element. |
| style | `React.CSSProperties` | - | Style applied to the element. |
| render | `React.ReactElement \| ((props, state) => React.ReactElement)` | - | Replaces the element with another tag or component, or composes it with one. Takes an element, or a function that receives the props and the state. |

### Lightbox.Backdrop

Covers the page behind the viewer. While the image is pulled to dismiss, it
gets `--lightbox-dismiss-progress` (0 to 1), to fade it out:
`opacity: calc(1 - var(--lightbox-dismiss-progress, 0))`. While the image
flies back, it has `data-ending-style` like any Base UI popup. It has
`data-swiping` during a drag: turn its transition off then, or the fade
lags behind the finger.
Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| forceRender | `boolean` | `false` | Whether the backdrop is forced to render even when nested. |
| className | `string` | - | CSS class applied to the element. |
| style | `React.CSSProperties` | - | Style applied to the element. |
| render | `React.ReactElement \| ((props, state) => React.ReactElement)` | - | Replaces the element with another tag or component, or composes it with one. Takes an element, or a function that receives the props and the state. |

| Data attribute | Description |
| --- | --- |
| `data-ending-style` | Present while the image flies back or fades out. |
| `data-swiping` | Present while a finger or the mouse swipes or pulls the image, and until it settles back after a pull to close. Turn transitions off meanwhile, so what follows `--lightbox-dismiss-progress` keeps up with the finger. |

| CSS variable | Description |
| --- | --- |
| `--lightbox-flight-duration` | Length of the flight the image is on, or was last on: `350ms`. Fade the backdrop over it: `transition-duration: var(--lightbox-flight-duration)`. |
| `--lightbox-flight-easing` | Curve of that flight: a `cubic-bezier()`, or a `linear()` spring after a pull to dismiss. `transition-timing-function: var(--lightbox-flight-easing)`. |
| `--lightbox-dismiss-progress` | Progress of the pull to dismiss: 0 at rest, 1 at the closing distance. Fade with `opacity: calc(1 - var(--lightbox-dismiss-progress, 0))`. |
| `--lightbox-dismiss-movement-x` | How far the image is pulled to dismiss, from where it rests. |
| `--lightbox-dismiss-movement-y` | How far the image is pulled to dismiss, from where it rests. |

### Lightbox.Popup

The full-screen viewer. Place `Lightbox.Viewport` and the controls inside.
It takes focus when it opens, so its keys work at once; Tab moves on to
the controls. Pass `initialFocus` to focus another element.

Keys: ← → previous / next (pan while zoomed), ↑ ↓ pan, Home / End first /
last, + − zoom, 0 reset zoom, Esc close. Inside a widget that uses these
keys itself (a Base UI `Toolbar`, `Lightbox.ThumbnailList`, a slider, a text
field), they stay that widget's.

Has `data-zoomed` while the image is zoomed, `data-controls-hidden` after a
tap hides the controls, and `data-ending-style` while the image flies back
(fade the controls then, not the popup: the image is inside it). Gets
`--lightbox-dismiss-progress` while the image is pulled to dismiss,
`--lightbox-swipe-movement-x` and `--lightbox-swipe-progress` while a swipe
moves the slides, and has `data-swiping` during a drag: turn transitions
off then.
Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| initialFocus | `boolean \| React.RefObject<HTMLElement \| null> \| ((openType: InteractionType) => boolean \| HTMLElement \| null \| void)` | - | Determines the element to focus when the dialog is opened. By default, focus moves to the first tabbable element inside the popup, except when the dialog is opened by touch — then the popup itself is focused to avoid opening the virtual keyboard. - `false`: Do not move focus. - `true`: Move focus based on the default behavior (first tabbable element or popup). - `RefObject`: Move focus to the ref element. - `function`: Called with the interaction type (`mouse`, `touch`, `pen`, or `keyboard`). Return an element to focus, `true` to use the default behavior, `null` to fall back to the default behavior, or `false`/`undefined` to do nothing. |
| finalFocus | `boolean \| React.RefObject<HTMLElement \| null> \| ((closeType: InteractionType) => boolean \| HTMLElement \| null \| void)` | - | Determines the element to focus when the dialog is closed. - `false`: Do not move focus. - `true`: Move focus based on the default behavior (trigger or previously focused element). - `RefObject`: Move focus to the ref element. - `function`: Called with the interaction type (`mouse`, `touch`, `pen`, or `keyboard`). Return an element to focus, `true` to use the default behavior, `null` to fall back to the default behavior, or `false`/`undefined` to do nothing. |
| className | `string` | - | CSS class applied to the element. |
| style | `React.CSSProperties` | - | Style applied to the element. |
| render | `React.ReactElement \| ((props, state) => React.ReactElement)` | - | Replaces the element with another tag or component, or composes it with one. Takes an element, or a function that receives the props and the state. |

| Data attribute | Description |
| --- | --- |
| `data-zoomed` | Present while the active image is zoomed in. |
| `data-controls-hidden` | Present after a tap hid the controls (toolbar, caption, close button). |
| `data-ending-style` | Present while the image flies back or fades out. Fade the controls then, not the popup: the image is inside it. |
| `data-swiping` | Present while a finger or the mouse swipes or pulls the image, and until it settles back after a pull to close. Turn transitions off meanwhile, so what follows `--lightbox-dismiss-progress` keeps up with the finger. |
| `data-activation-direction` | Side of the previous item the item shown came from, on screen: `right` after Next in a left-to-right layout. `none` when the viewer opens. |

| CSS variable | Description |
| --- | --- |
| `--lightbox-flight-duration` | Length of the flight the image is on, or was last on: `350ms`. Time a transition that goes with it (the backdrop, the controls) with it: `transition-duration: var(--lightbox-flight-duration)`. |
| `--lightbox-flight-easing` | Curve of that flight: a `cubic-bezier()`, or a `linear()` spring that starts at the speed of the pull that dismissed the viewer. Use it with `--lightbox-flight-duration` so the transition and the image arrive together. |
| `--lightbox-flight-movement-x` | How far the image flies from where it rests in the viewer, horizontally: to the trigger it lands on when the viewer closes, from the trigger it leaves when it opens. Set at the start of each flight. Move other content along with the image: `translate: var(--lightbox-flight-movement-x) var(--lightbox-flight-movement-y)`, with `--lightbox-flight-duration`. |
| `--lightbox-flight-movement-y` | The same, vertically. |
| `--lightbox-flight-scale` | The image's size at that trigger, as a share of its size in the viewer: `0.2` when it lands on a tile a fifth as wide. `1` before any flight. |
| `--lightbox-dismiss-progress` | Progress of the pull to dismiss: 0 at rest, 1 at the closing distance. Fade with `opacity: calc(1 - var(--lightbox-dismiss-progress, 0))`. |
| `--lightbox-swipe-movement-x` | How far the slides have moved from the one shown, on screen: negative to the left. Set while a swipe drags them and while they settle, `0px` at rest. |
| `--lightbox-swipe-progress` | The same as a share of one slide: `0` at rest, `1` a whole slide to the right, `-1` to the left. Move things with the swipe: `translate: calc(var(--lightbox-swipe-progress, 0) * 2rem) 0`. |
| `--lightbox-swipe-strength` | How hard a released swipe was flicked, `0.1` to `1` (a slow release), like Base UI's drawer: the slides settle in 300ms times it. Scale a transition that follows them: `transition-duration: calc(300ms * var(--lightbox-swipe-strength, 1))`. |
| `--lightbox-dismiss-movement-x` | How far the image is pulled to dismiss, from where it rests. |
| `--lightbox-dismiss-movement-y` | How far the image is pulled to dismiss, from where it rests. |
| `--lightbox-zoom-scale` | Scale of the active image: `1` at rest. |
| `--lightbox-zoom-progress` | The zoom from rest to the largest scale: `0` to `1`. Fade the controls as the image zooms in: `opacity: calc(1 - var(--lightbox-zoom-progress, 0))`. |

### Lightbox.Title

Names the viewer for screen readers. Hide it visually if the design has no
visible title. With a function child, it names the item shown, and has
`data-starting-style` for a frame when the item changes, with
`data-activation-direction`, to animate the new title in.
Renders an `<h2>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| children | `React.ReactNode \| ((item: any) => React.ReactNode)` | - | Content. A function receives the item shown in the viewer, as passed to `items`, to name it: `{(photo) => photo.title}`. |
| className | `string` | - | CSS class applied to the element. |
| style | `React.CSSProperties` | - | Style applied to the element. |
| render | `React.ReactElement \| ((props, state) => React.ReactElement)` | - | Replaces the element with another tag or component, or composes it with one. Takes an element, or a function that receives the props and the state. |

| Data attribute | Description |
| --- | --- |
| `data-activation-direction` | Side of the previous item the item shown came from, on screen: `right` after Next in a left-to-right layout. `none` when the viewer opens. |
| `data-starting-style` | Present for one frame when the item shown changes, with a function child: style where the new content starts from, with transitions off (`data-starting-style:transition-none`), and a transition animates it in. |

### Lightbox.Description

Describes the item shown in the viewer, such as its caption, linked to the
viewer for screen readers. With a function child, it has
`data-starting-style` for a frame when the item changes, with
`data-activation-direction`, to animate the new caption in. Renders
nothing when its content is empty.
Renders a `<p>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| children | `React.ReactNode \| ((item: any) => React.ReactNode)` | - | Content. A function receives the item shown in the viewer, as passed to `items`, to show its caption: `{(photo) => photo.caption}`. |
| className | `string` | - | CSS class applied to the element. |
| style | `React.CSSProperties` | - | Style applied to the element. |
| render | `React.ReactElement \| ((props, state) => React.ReactElement)` | - | Replaces the element with another tag or component, or composes it with one. Takes an element, or a function that receives the props and the state. |

| Data attribute | Description |
| --- | --- |
| `data-activation-direction` | Side of the previous item the item shown came from, on screen: `right` after Next in a left-to-right layout. `none` when the viewer opens. |
| `data-starting-style` | Present for one frame when the item shown changes, with a function child: style where the new content starts from, with transitions off (`data-starting-style:transition-none`), and a transition animates it in. |

### Lightbox.Viewport

The window the slides swipe through: a carousel region that handles the
gestures (swipe, pinch, double tap, pan, pull to dismiss, wheel zoom). Give
it a size (usually `position: absolute; inset: 0` in the popup), and set
the space between slides with `column-gap` (`gap-4`).
One slide of finger travel is the viewport's width plus that gap. When the
CSS draws the slides closer together, as a coverflow does, set
`--lightbox-swipe-distance` on it so they follow the finger.
It doesn't clip: the slides next to the one shown, the zoomed image and the
media pulled to dismiss are drawn past its edges. In a viewer smaller than
the screen, clip them with `overflow: hidden`; leave it off for a pulled
card or a 3D transition to leave the viewport.
A press that starts on a control inside a slide (a button, a link, a
player) is left to that control, and so is one on content marked with
Base UI's `data-base-ui-swipe-ignore`, such as a 3D model that rotates
with a drag: with no value, all its drags; with `"x"` or `"y"`, only
those along that axis. It has `data-swiping` while a finger or
the mouse drags, and `data-activation-direction` after the item shown
changes.
Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| children (required) | `(item: any, index: number) => React.ReactNode` | - | Renders one item: a `Lightbox.Slide` with a `Lightbox.Image` or other content inside. Receives the item as passed to `items`. Called only for the active item and its `neighbors`, and the items a swipe brings into view. |
| neighbors | `number` | `1` | Items rendered on each side of the one shown. Raise it for a transition that shows more than the next item, like a coverflow; the others stay empty until they come near. While swiping, items around the visible part of the track are also rendered. |
| className | `string` | - | CSS class applied to the element. |
| style | `React.CSSProperties` | - | Style applied to the element. |
| render | `React.ReactElement \| ((props, state) => React.ReactElement)` | - | Replaces the element with another tag or component, or composes it with one. Takes an element, or a function that receives the props and the state. |

| Data attribute | Description |
| --- | --- |
| `data-zoomed` | Present while the active image is zoomed in. |
| `data-swiping` | Present while a finger or the mouse swipes, pans or pulls to dismiss. |
| `data-activation-direction` | Side of the previous item the item shown came from, on screen: `right` after Next in a left-to-right layout. `none` when the viewer opens. |

| CSS variable | Description |
| --- | --- |
| `--lightbox-swipe-distance` | How far the finger travels to move one slide: any CSS length. Set it yourself on the viewport; the viewport reads it. By default, one slide is the viewport's width plus its `column-gap`. Set it when the CSS draws the slides closer together than that, as a coverflow does, so they follow the finger instead of lagging behind it: `[--lightbox-swipe-distance:calc(var(--cover-size)*0.68)]`. |

### Lightbox.Slide

One slide of the viewport, as large as the viewport. Return it from the
function passed to `Lightbox.Viewport`. Its padding is the space kept
around the media (`p-6 md:p-24`, or `p-0` for edge to edge). Put a
`Lightbox.Media` inside, and a `Lightbox.Caption`, or any other content.
It has `--lightbox-slide-progress`, its distance from the centre, and it
is placed with `translate`, so its `transform` is free for a custom
transition between slides.
With a `Lightbox.ThumbnailList`, it is the tab panel of its thumbnail.
Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| className | `string` | - | CSS class applied to the element. |
| style | `React.CSSProperties` | - | Style applied to the element. |
| render | `React.ReactElement \| ((props, state) => React.ReactElement)` | - | Replaces the element with another tag or component, or composes it with one. Takes an element, or a function that receives the props and the state. |

| Data attribute | Description |
| --- | --- |
| `data-active` | Present on the slide shown in the viewer. |
| `data-loading` | Present while its `Lightbox.Media` loads. |
| `data-error` | Present when its `Lightbox.Media` failed to load. |
| `data-activation-direction` | Side of the previous item the item shown came from, on screen: `right` after Next in a left-to-right layout. `none` when the viewer opens. |

| CSS variable | Description |
| --- | --- |
| `--lightbox-slide-progress` | How far the slide is from the centre of the viewport, in slides, on screen: `0` when it is the one shown and at rest, `-1` one slide to the left, `1` one to the right, in between while a swipe, its settle or Next moves the slides. The slide is placed with `translate`, so its `transform` is free for a custom transition, such as a cube: `transform: rotateY(calc(var(--lightbox-slide-progress) * 90deg))` with a `perspective` on `Lightbox.Viewport`. |

### Lightbox.Media

The media of a `Lightbox.Slide`: put an `<img>`, a `<video>` or an
`<iframe>` inside, as its direct child. It is a box with the media's
shape, fitted inside the slide's padding, that the media fills. The box
flies from the trigger and back and follows the pull to dismiss, with all
of its content: put a spinner or an error message next to the media and
show them with the slide's `data-loading` and `data-error`. An `<img>`
also zooms and pans; other media don't. A `<video>` pauses when its
slide is no longer shown. The loading state comes from the
media's own events. Its position and size are set by the box, and its
`translate` and `scale` while it is pulled to dismiss.
Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| width | `number \| string` | - | Width of the media, when known. With `height`, it sets the box's shape, before the media has loaded too, so the image flies in at the right size. Without both, the box takes the shape of the loaded media. A shape other than the media's keeps every slide the same: fit the media in it with `object-fit`. |
| height | `number \| string` | - | Height of the media, when known. See `width`. |
| fill | `boolean` | `false` | Whether the box fills the slide, inside its padding, whatever the shape of the media: `width`, `height` and the loaded size don't change it. Size the slide with CSS, and fit the media in it with `object-fit`. |
| className | `string` | - | CSS class applied to the element. |
| style | `React.CSSProperties` | - | Style applied to the element. |
| render | `React.ReactElement \| ((props, state) => React.ReactElement)` | - | Replaces the element with another tag or component, or composes it with one. Takes an element, or a function that receives the props and the state. |

| Data attribute | Description |
| --- | --- |
| `data-active` | Present on the media of the slide shown in the viewer. |
| `data-loading` | Present while the media loads. |
| `data-error` | Present when the media failed to load. |

### Lightbox.Caption

The text of a `Lightbox.Slide`, such as its title or credits. Place it in
the slide, next to `Lightbox.Media`: it moves with the slide, and
describes it for screen readers. It has `data-starting-style` for a frame
when its slide becomes the one shown, with `data-activation-direction`, to
animate it in. Renders nothing when its content is empty.
Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| className | `string` | - | CSS class applied to the element. |
| style | `React.CSSProperties` | - | Style applied to the element. |
| render | `React.ReactElement \| ((props, state) => React.ReactElement)` | - | Replaces the element with another tag or component, or composes it with one. Takes an element, or a function that receives the props and the state. |

| Data attribute | Description |
| --- | --- |
| `data-active` | Present when its slide is the one shown in the viewer. |
| `data-activation-direction` | Side of the previous item the item shown came from, on screen. |
| `data-starting-style` | Present for one frame when its slide becomes the one shown: style where the caption starts from, with transitions off (`data-starting-style:transition-none`), and a transition animates it in. |

### Lightbox.Previous

Shows the previous item. Disabled on the first one.
Renders a `<button>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| className | `string` | - | CSS class applied to the element. |
| style | `React.CSSProperties` | - | Style applied to the element. |
| render | `React.ReactElement \| ((props, state) => React.ReactElement)` | - | Replaces the element with another tag or component, or composes it with one. Takes an element, or a function that receives the props and the state. |

| Data attribute | Description |
| --- | --- |
| `data-disabled` | Present when the action can't run now. |

### Lightbox.Next

Shows the next item. Disabled on the last one.
Renders a `<button>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| className | `string` | - | CSS class applied to the element. |
| style | `React.CSSProperties` | - | Style applied to the element. |
| render | `React.ReactElement \| ((props, state) => React.ReactElement)` | - | Replaces the element with another tag or component, or composes it with one. Takes an element, or a function that receives the props and the state. |

| Data attribute | Description |
| --- | --- |
| `data-disabled` | Present when the action can't run now. |

### Lightbox.Counter

Position of the active item, as "2 / 5". Announced to screen readers when
it changes. It has `data-starting-style` for a frame when the item
changes, with `data-activation-direction`, to animate the new number in.
Renders a `<span>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| children | `React.ReactNode \| ((state: LightboxCounterState) => React.ReactNode)` | ``${index + 1} / ${count}`` | Content. A function receives the state, to format it. |
| className | `string` | - | CSS class applied to the element. |
| style | `React.CSSProperties` | - | Style applied to the element. |
| render | `React.ReactElement \| ((props, state) => React.ReactElement)` | - | Replaces the element with another tag or component, or composes it with one. Takes an element, or a function that receives the props and the state. |

| Data attribute | Description |
| --- | --- |
| `data-activation-direction` | Side of the previous item the item shown came from, on screen: `right` after Next in a left-to-right layout. `none` when the viewer opens. |
| `data-starting-style` | Present for one frame when the item shown changes: style where the new content starts from, with transitions off (`data-starting-style:transition-none`), and a transition animates it in. |

### Lightbox.ZoomIn

Zooms the active image in by one step. Disabled at the largest scale.
Renders a `<button>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| className | `string` | - | CSS class applied to the element. |
| style | `React.CSSProperties` | - | Style applied to the element. |
| render | `React.ReactElement \| ((props, state) => React.ReactElement)` | - | Replaces the element with another tag or component, or composes it with one. Takes an element, or a function that receives the props and the state. |

| Data attribute | Description |
| --- | --- |
| `data-disabled` | Present when the action can't run now. |

### Lightbox.ZoomOut

Zooms the active image out by one step. Disabled when it isn't zoomed.
Renders a `<button>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| className | `string` | - | CSS class applied to the element. |
| style | `React.CSSProperties` | - | Style applied to the element. |
| render | `React.ReactElement \| ((props, state) => React.ReactElement)` | - | Replaces the element with another tag or component, or composes it with one. Takes an element, or a function that receives the props and the state. |

| Data attribute | Description |
| --- | --- |
| `data-disabled` | Present when the action can't run now. |

### Lightbox.ZoomReset

Puts the active image back at its original size. Disabled when it isn't
zoomed. Renders a `<button>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| className | `string` | - | CSS class applied to the element. |
| style | `React.CSSProperties` | - | Style applied to the element. |
| render | `React.ReactElement \| ((props, state) => React.ReactElement)` | - | Replaces the element with another tag or component, or composes it with one. Takes an element, or a function that receives the props and the state. |

| Data attribute | Description |
| --- | --- |
| `data-disabled` | Present when the action can't run now. |

### Lightbox.ZoomValue

Zoom level of the active image, as a percentage.
Renders a `<span>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| children | `React.ReactNode \| ((state: LightboxZoomValueState) => React.ReactNode)` | ``${Math.round(scale * 100)}%`` | Content. A function receives the state, to format the scale. |
| className | `string` | - | CSS class applied to the element. |
| style | `React.CSSProperties` | - | Style applied to the element. |
| render | `React.ReactElement \| ((props, state) => React.ReactElement)` | - | Replaces the element with another tag or component, or composes it with one. Takes an element, or a function that receives the props and the state. |

### Lightbox.Close

Closes the viewer; the image flies back to its trigger.
Renders a `<button>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| nativeButton | `boolean` | `true` | Whether the component renders a native `<button>` element when replacing it via the `render` prop. Set to `false` if the rendered element is not a button (for example, `<div>`). |
| className | `string` | - | CSS class applied to the element. |
| style | `React.CSSProperties` | - | Style applied to the element. |
| render | `React.ReactElement \| ((props, state) => React.ReactElement)` | - | Replaces the element with another tag or component, or composes it with one. Takes an element, or a function that receives the props and the state. |

### Lightbox.ThumbnailList

A strip of thumbnails inside the viewer, one per item. It is a single tab
stop: the arrow keys move between thumbnails and show their item, Home and
End go to the first and last. It scrolls itself: the active thumbnail
glides to its centre, the mouse can drag it, and the wheel scrolls a
horizontal strip. Make it scroll with `overflow: auto`, and hide its
scrollbar in CSS if you like. Follows the WAI-ARIA tabbed carousel
pattern: it is a tab list, and the slides are its tab panels.
Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| children (required) | `\| LightboxThumbnailRender \| ReadonlyArray<LightboxThumbnailRender \| React.ReactNode>` | - | A function that renders one thumbnail: a `Lightbox.Thumbnail` with an image inside. Receives the item as passed to `items`, and is called for every item. Other children, such as a `Lightbox.ThumbnailIndicator`, render after the thumbnails. |
| orientation | `"horizontal" \| "vertical"` | `"horizontal"` | Which arrow keys move between thumbnails, and which way the strip scrolls. Set as `data-orientation`. `"horizontal"`: Left to right (right to left in a right-to-left layout); ← → move, and the wheel scrolls the strip sideways. `"vertical"`: Top to bottom; ↑ ↓ move. |
| activateOnFocus | `boolean` | `true` | Whether the arrow keys also show the item of the thumbnail they move to. When `false`, they only move focus, and Enter shows the item. |
| className | `string` | - | CSS class applied to the element. |
| style | `React.CSSProperties` | - | Style applied to the element. |
| render | `React.ReactElement \| ((props, state) => React.ReactElement)` | - | Replaces the element with another tag or component, or composes it with one. Takes an element, or a function that receives the props and the state. |

| Data attribute | Description |
| --- | --- |
| `data-orientation` | Whether the thumbnails run left to right or top to bottom. |
| `data-overflow-start` | Present while thumbnails are scrolled out of view before the start: fade that edge with a mask. |
| `data-overflow-end` | Present while thumbnails are scrolled out of view past the end. |
| `data-swiping` | Present while the mouse swipes the strip. Turn off snapping and transitions meanwhile. |

### Lightbox.Thumbnail

Shows its item in the viewer. Return it from the function passed to
`Lightbox.ThumbnailList`, with the thumbnail image inside, such as a Base UI
`Avatar` that handles loading and errors, or anything else, such as a dot.
It is a tab: it has `aria-selected` and `data-active` while its item is
shown, and the list keeps it centred.
Renders a `<button>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| className | `string` | - | CSS class applied to the element. |
| style | `React.CSSProperties` | - | Style applied to the element. |
| render | `React.ReactElement \| ((props, state) => React.ReactElement)` | - | Replaces the element with another tag or component, or composes it with one. Takes an element, or a function that receives the props and the state. |

| Data attribute | Description |
| --- | --- |
| `data-active` | Present on the thumbnail of the item shown in the viewer. |

### Lightbox.ThumbnailIndicator

Marks the active thumbnail with one element that moves from thumbnail to
thumbnail, like Base UI's `Tabs.Indicator`. Place it in
`Lightbox.ThumbnailList`, after the function child, and make the list
`position: relative`: it gets the active thumbnail's box in the list as
CSS variables, and scrolls with the list. They move with the slides on
every frame (a swipe, its settle, Next), so style it with them and no
transition: `left: var(--active-thumbnail-left)`. Hidden from screen readers; renders
nothing until it has measured a thumbnail.
Renders a `<span>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| className | `string` | - | CSS class applied to the element. |
| style | `React.CSSProperties` | - | Style applied to the element. |
| render | `React.ReactElement \| ((props, state) => React.ReactElement)` | - | Replaces the element with another tag or component, or composes it with one. Takes an element, or a function that receives the props and the state. |

| Data attribute | Description |
| --- | --- |
| `data-activation-direction` | Side of the previous item the item shown came from, on screen. |

| CSS variable | Description |
| --- | --- |
| `--active-thumbnail-left` | Distance from the left of the thumbnail list's content to the active thumbnail. While a swipe drags the slides, it moves towards the next or previous thumbnail with them. |
| `--active-thumbnail-top` | Distance from the top of the thumbnail list's content to the active thumbnail. |
| `--active-thumbnail-width` | Width of the active thumbnail. |
| `--active-thumbnail-height` | Height of the active thumbnail. |

### LightboxMediaStatus

Loading state of the media in a `Lightbox.Slide`.

| Value | Description |
| --- | --- |
| `"idle"` | No `Lightbox.Media` in the slide. |
| `"loading"` | The media is loading; the slide and the media have `data-loading`. |
| `"loaded"` | The media has loaded, or has nothing to load. |
| `"error"` | The media failed to load; the slide and the media have `data-error`. |

### LightboxRootActivationDirection

Side of the previous item the item shown came from, on screen. Set as
`data-activation-direction`, like Base UI's tabs.

| Value | Description |
| --- | --- |
| `"none"` | The viewer just opened, or the item shown didn't move. |
| `"left"` | It came from the left: after Previous in a left-to-right layout, Next in a right-to-left one. |
| `"right"` | It came from the right: after Next in a left-to-right layout, Previous in a right-to-left one. |

### LightboxRootChangeEventDetails

Details of a change: its `reason`, the native `event`, and the element it
came from (`trigger`). Call `cancel()` to stop it; a cancelled swipe or
pull to dismiss springs back.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| reason (required) | `"trigger-press" \| "swipe" \| "keyboard" \| "list-navigation" \| "thumbnail-press" \| "previous-press" \| "next-press" \| "close-press" \| "escape-key" \| "outside-press" \| "focus-out" \| "imperative-action" \| "missing" \| "none"` | - | The reason for the event. |
| event (required) | `ReasonToEvent<Reason>` | - | The native event associated with the custom event. |
| cancel (required) | `() => void` | - | Cancels Base UI from handling the event. |
| allowPropagation (required) | `() => void` | - | Allows the event to propagate in cases where Base UI will stop the propagation. |
| isCanceled (required) | `boolean` | - | Indicates whether the event has been canceled. |
| isPropagationAllowed (required) | `boolean` | - | Indicates whether the event is allowed to propagate. |
| trigger (required) | `Element \| undefined` | - | The element that triggered the event, if applicable. |

### LightboxRootChangeEventReason

Why the viewer opened or closed, in `onOpenChange`, or showed another
item, in `onValueChange`. The values are Base UI's where it has one
(`escape-key`, `close-press`), so a reason compares with the same string
as in a Base UI dialog.

| Value | Description |
| --- | --- |
| `"trigger-press"` | A `Lightbox.Trigger` was pressed. |
| `"swipe"` | A swipe to the next or previous item, or a pull to dismiss. |
| `"keyboard"` | A viewer shortcut: ← → Home End. |
| `"list-navigation"` | The arrow keys in a `Lightbox.ThumbnailList` with `activateOnFocus`. |
| `"thumbnail-press"` | A `Lightbox.Thumbnail` was pressed. |
| `"previous-press"` | `Lightbox.Previous` was pressed. |
| `"next-press"` | `Lightbox.Next` was pressed. |
| `"close-press"` | `Lightbox.Close` was pressed. |
| `"escape-key"` | Escape was pressed. |
| `"outside-press"` | A press outside the popup. |
| `"focus-out"` | Focus left the popup. |
| `"imperative-action"` | A call on the `handle`: `open()` or `close()`. |
| `"missing"` | The open item was removed from `items`: the viewer shows its neighbour, or closes. |
| `"none"` | Another cause, such as a Base UI dialog change without a reason. |

### LightboxRootCloseTarget

Where the image flies back to when the viewer closes, and where focus returns.

| Value | Description |
| --- | --- |
| `"active"` | The trigger of the item shown. Without one on the page, the image fades out. For a grid with a trigger per item. |
| `"opener"` | The trigger that opened the viewer, whichever item is shown. For one cover that opens a whole album. |
| `"nearest"` | The trigger of the item shown, or else the trigger of the nearest item that has one. For a grid that shows a few items and a "+5" tile. |

### LightboxRootPhase

Where the viewer is in its open and close animation.

| Value | Description |
| --- | --- |
| `"closed"` | The viewer is not shown. |
| `"opening"` | The image flies from its trigger, or fades in. |
| `"open"` | The image has landed in the viewer. |
| `"closing"` | The image flies back to its trigger, or fades out. |

### LightboxThumbnailListOrientation

Which way the thumbnails run, and which arrow keys move between them. Set
as `data-orientation`.

| Value | Description |
| --- | --- |
| `"horizontal"` | Left to right (right to left in a right-to-left layout); ← → move, and the wheel scrolls the strip sideways. |
| `"vertical"` | Top to bottom; ↑ ↓ move. |

### LightboxItemValue

Identifies an item, as returned by `itemToValue`: stable when items are
added, removed or reordered.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| toString (required) | `undefined` | - | - |
| valueOf (required) | `undefined` | - | - |
| toLocaleString (required) | `undefined` | - | - |

### LightboxMediaSize

Natural size of a media element, in px.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| width (required) | `number` | - | - |
| height (required) | `number` | - | - |
