{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "zoom-lightbox",
  "type": "registry:ui",
  "title": "Zoom Lightbox",
  "description": "A thumbnail that grows into a full-screen photo, pages sideways, and shrinks back into its tile when you drag it down, like Apple Photos.",
  "dependencies": [
    "motion",
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/zoom-lightbox.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { animate, motion, useMotionValue, useTransform, type MotionValue } from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/* ─────────────────────────────────────────────────────────\n * ZOOM LIGHTBOX STORYBOARD\n *\n *  open    tap a tile: the photo lifts out of the grid and grows\n *          to fit the screen, uncropping from the tile's shape\n *          and squaring its corners while the page fades away\n *  page    drag sideways, press ← →, or swipe a trackpad: one\n *          photo at a time, rubber-banding past either end.\n *          The page scrolls behind so every photo has a tile\n *          to return to\n *  lift    drag down: the photo comes loose under the finger,\n *          shrinking and rounding as the page shows through\n *  home    let go moving down and it flies back into its tile\n *          at the finger's speed; let go moving up and it\n *          springs back to full screen\n *  catch   grab it on the way home to pull it back out\n *  dim     tap the photo, the moon button or H: the controls\n *          hide on black, and a short note says how to get\n *          them back, in the words of whatever dimmed it\n *  close   tap outside the photo, Esc or ×: send it home\n * ───────────────────────────────────────────────────────── */\n\nconst LIGHTBOX = {\n  open: { type: \"spring\", visualDuration: 0.42, bounce: 0.12 },\n  home: { type: \"spring\", visualDuration: 0.4, bounce: 0.16 },\n  settle: { type: \"spring\", visualDuration: 0.36, bounce: 0.2 }, // a cancelled drag springs back\n  page: { type: \"spring\", visualDuration: 0.38, bounce: 0 },\n  fadeIn: { duration: 0.3, ease: \"easeOut\" },\n  fadeOut: { duration: 0.18, ease: \"easeOut\" },\n  gap: 24, // px between photos while paging\n  lock: 8, // px a press travels before a drag commits to an axis\n  minScale: 0.5, // a dragged photo shrinks no smaller than this\n  shrink: 1.1, // drag distance, in viewport heights, to reach minScale\n  reveal: 0.45, // drag distance, in viewport heights, to fade the page in fully\n  dismiss: 0.12, // projected drag, in viewport heights, that sends a photo home\n  flick: 400, // px/s; a faster release counts as a flick\n  lift: 16, // corner radius a dragged photo picks up, px\n} as const;\n\n// How to bring the controls back, worded for whatever hid them.\nconst HINTS = {\n  touch: \"Tap the photo to show controls\",\n  mouse: \"Click the photo or press H to show controls\",\n  key: \"Press H to show controls\",\n};\ntype HintSource = keyof typeof HINTS;\n\n// Room around the photo for the controls. Below `narrow` it runs edge to edge.\nconst FRAME = { narrow: 640, inset: { x: 0, y: 64 }, wideInset: { x: 88, y: 72 } };\n\ntype Viewport = { width: number; height: number };\ntype Box = { x: number; y: number; width: number; height: number };\n/**\n * Where the photo sits relative to its resting box: an offset and scale, the\n * crop from each edge in the photo's own px, and the corner radius in screen px.\n */\ntype Pose = {\n  x: number;\n  y: number;\n  scale: number;\n  top: number;\n  right: number;\n  bottom: number;\n  left: number;\n  radius: number;\n};\n\ntype ItemData = {\n  src: string;\n  thumbnail?: string;\n  alt: string;\n  caption?: React.ReactNode;\n  width?: number;\n  height?: number;\n};\ntype Entry = ItemData & { button: HTMLButtonElement | null; image: HTMLImageElement | null };\ntype Slide = ItemData & { id: string; aspect: number };\ntype Session = {\n  key: number;\n  slides: Slide[];\n  index: number;\n  origin: Pose | null;\n  viewport: Viewport;\n  reduceMotion: boolean;\n};\n\nconst LightboxContext = React.createContext<{\n  entries: Map<string, Entry>;\n  open: (id: string) => void;\n} | null>(null);\n\nexport interface ZoomLightboxProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** Classes for the backdrop behind an open photo. Defaults to `bg-background`. */\n  overlayClassName?: string;\n}\n\n/**\n * Groups `ZoomLightboxItem`s. Items can sit anywhere inside it, in any layout;\n * the viewer pages through them in document order.\n */\nexport function ZoomLightbox({ overlayClassName, className, children, ...props }: ZoomLightboxProps) {\n  const [entries] = React.useState(() => new Map<string, Entry>());\n  const [session, setSession] = React.useState<Session | null>(null);\n  const sessions = React.useRef(0);\n\n  const open = React.useCallback(\n    (id: string) => {\n      const ordered = [...entries]\n        .flatMap(([key, entry]) => (entry.button?.isConnected ? [{ key, entry, button: entry.button }] : []))\n        .sort((a, b) =>\n          a.button.compareDocumentPosition(b.button) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1,\n        );\n      const index = ordered.findIndex(({ key }) => key === id);\n      if (index === -1) return;\n\n      const slides = ordered.map(({ key, entry }) => ({\n        id: key,\n        src: entry.src,\n        thumbnail: entry.thumbnail,\n        alt: entry.alt,\n        caption: entry.caption,\n        aspect: aspectOf(entry),\n      }));\n      const viewport = readViewport();\n      const reduceMotion = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n      const origin = reduceMotion\n        ? null\n        : tilePose(entries.get(id), fit(slides[index].aspect, viewport), viewport);\n      for (const slide of slides.slice(Math.max(index - 1, 0), index + 2)) preload(slide.src);\n\n      sessions.current += 1;\n      const next = { key: sessions.current, slides, index, origin, viewport, reduceMotion };\n      setSession((current) => current ?? next);\n    },\n    [entries],\n  );\n\n  const context = React.useMemo(() => ({ entries, open }), [entries, open]);\n\n  return (\n    <LightboxContext.Provider value={context}>\n      <div className={className} {...props}>\n        {children}\n      </div>\n      {session ? (\n        <Viewer\n          key={session.key}\n          session={session}\n          entries={entries}\n          overlayClassName={overlayClassName}\n          onClosed={() => setSession(null)}\n        />\n      ) : null}\n    </LightboxContext.Provider>\n  );\n}\n\nexport interface ZoomLightboxItemProps\n  extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, \"children\"> {\n  /** The full-size image shown in the viewer. */\n  src: string;\n  /** A smaller image for the tile. Defaults to `src`. Should have the same aspect ratio. */\n  thumbnail?: string;\n  /** Describes the image. Used for the tile and the viewer. */\n  alt: string;\n  /** Shown under the photo in the viewer. */\n  caption?: React.ReactNode;\n  /** Intrinsic width of `src`. With `height`, sizes the viewer before the image loads. */\n  width?: number;\n  /** Intrinsic height of `src`. */\n  height?: number;\n  /** Classes for the tile's `<img>`. */\n  imageClassName?: string;\n}\n\n/** A tile that opens its photo in the viewer. Size and round it with `className`. */\nexport function ZoomLightboxItem({\n  src,\n  thumbnail,\n  alt,\n  caption,\n  width,\n  height,\n  className,\n  imageClassName,\n  onClick,\n  onPointerEnter,\n  onFocus,\n  ...props\n}: ZoomLightboxItemProps) {\n  const { entries, open } = useLightbox();\n  const id = React.useId();\n  const buttonRef = React.useRef<HTMLButtonElement>(null);\n  const imageRef = React.useRef<HTMLImageElement>(null);\n\n  React.useLayoutEffect(() => {\n    entries.set(id, {\n      src,\n      thumbnail,\n      alt,\n      caption,\n      width,\n      height,\n      button: buttonRef.current,\n      image: imageRef.current,\n    });\n  });\n  React.useLayoutEffect(() => () => void entries.delete(id), [entries, id]);\n\n  // Warm up the full-size image while the pointer is on its way.\n  const warm = () => {\n    if (thumbnail && thumbnail !== src) preload(src);\n  };\n\n  return (\n    <button\n      ref={buttonRef}\n      type=\"button\"\n      aria-haspopup=\"dialog\"\n      className={cn(\n        \"group relative block cursor-zoom-in overflow-hidden bg-muted outline-none select-none [-webkit-tap-highlight-color:transparent]\",\n        \"transition-transform duration-200 ease-out active:scale-[0.98] motion-reduce:transition-none motion-reduce:active:scale-100\",\n        \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground\",\n        className,\n      )}\n      onClick={(event) => {\n        onClick?.(event);\n        if (!event.defaultPrevented) open(id);\n      }}\n      onPointerEnter={(event) => {\n        onPointerEnter?.(event);\n        warm();\n      }}\n      onFocus={(event) => {\n        onFocus?.(event);\n        warm();\n      }}\n      {...props}\n    >\n      <img\n        ref={imageRef}\n        src={thumbnail ?? src}\n        alt={alt}\n        loading=\"lazy\"\n        decoding=\"async\"\n        draggable={false}\n        className={cn(\n          \"size-full object-cover transition-transform duration-500 ease-out motion-safe:group-hover:scale-[1.03]\",\n          imageClassName,\n        )}\n      />\n    </button>\n  );\n}\n\ntype Drag = {\n  pointer: number;\n  mode: \"pending\" | \"page\" | \"lift\";\n  onPhoto: boolean;\n  startX: number;\n  startY: number;\n  samples: { x: number; y: number; t: number }[];\n  // page\n  from: number;\n  trackFrom: number;\n  // lift\n  centerX: number;\n  centerY: number;\n  grabX: number;\n  grabY: number;\n  scaleFrom: number;\n  backdropFrom: number;\n};\n\nfunction Viewer({\n  session,\n  entries,\n  overlayClassName,\n  onClosed,\n}: {\n  session: Session;\n  entries: Map<string, Entry>;\n  overlayClassName?: string;\n  onClosed: () => void;\n}) {\n  const { slides, origin, reduceMotion } = session;\n  const count = slides.length;\n\n  const [viewport, setViewport] = React.useState(session.viewport);\n  const [index, setIndex] = React.useState(session.index);\n  // The last index the track came to rest on. Photos between it and `index`\n  // stay mounted, so a fast run of ← → never flies past an empty slot.\n  const [anchor, setAnchor] = React.useState(session.index);\n  const [immersive, setImmersive] = React.useState(false);\n  const [aspects, setAspects] = React.useState<Record<string, number>>({});\n  const [hintText, setHintText] = React.useState(\"\");\n  const tooltipId = React.useId();\n\n  const dialogRef = React.useRef<HTMLDialogElement>(null);\n  const stageRef = React.useRef<HTMLDivElement>(null);\n  const live = React.useRef({\n    index: session.index,\n    closing: false,\n    done: false,\n    generation: 0,\n    hidden: null as HTMLElement | null,\n    drag: null as Drag | null,\n    wheel: { sum: 0, locked: false, timer: 0 },\n  });\n\n  // The open photo. It starts out posed exactly over its tile.\n  const x = useMotionValue(origin?.x ?? 0);\n  const y = useMotionValue(origin?.y ?? 0);\n  const scale = useMotionValue(origin?.scale ?? (reduceMotion ? 1 : 0.94));\n  const top = useMotionValue(origin?.top ?? 0);\n  const right = useMotionValue(origin?.right ?? 0);\n  const bottom = useMotionValue(origin?.bottom ?? 0);\n  const left = useMotionValue(origin?.left ?? 0);\n  const radius = useMotionValue(origin?.radius ?? 0);\n  const opacity = useMotionValue(origin ? 1 : 0);\n  const clipPath = useTransform(() => {\n    const crop = [top, right, bottom, left].map((v) => `${Math.max(v.get(), 0)}px`).join(\" \");\n    return `inset(${crop} round ${radius.get() / Math.max(scale.get(), 0.01)}px)`;\n  });\n  const pose = React.useMemo(\n    () => ({ x, y, scale, top, right, bottom, left, radius, opacity }),\n    [x, y, scale, top, right, bottom, left, radius, opacity],\n  );\n\n  const pitch = viewport.width + LIGHTBOX.gap;\n  const track = useMotionValue(-session.index * pitch);\n  const backdrop = useMotionValue(0);\n  const shade = useMotionValue(0); // black behind the photo while the controls are hidden\n  const shadeOpacity = useTransform(() => shade.get() * backdrop.get());\n  const chrome = useMotionValue(0);\n  const hint = useMotionValue(0);\n\n  const boxOf = (i: number) => fit(aspects[slides[i].id] ?? slides[i].aspect, viewport);\n\n  // Open: show the dialog, hide the tile the photo came from, and grow it in.\n  React.useLayoutEffect(() => {\n    const dialog = dialogRef.current;\n    if (!dialog) return;\n    if (!dialog.open) dialog.showModal();\n    stageRef.current?.focus({ preventScroll: true });\n    hideTile(live.current, entries, slides[session.index].id, reduceMotion);\n\n    if (origin) {\n      for (const value of [pose.x, pose.y, pose.top, pose.right, pose.bottom, pose.left, pose.radius]) {\n        animate(value, 0, LIGHTBOX.open);\n      }\n      animate(pose.scale, 1, { ...LIGHTBOX.open, restDelta: 0.001 });\n    } else {\n      animate(pose.opacity, 1, LIGHTBOX.fadeIn);\n      animate(pose.scale, 1, reduceMotion ? { duration: 0 } : LIGHTBOX.open);\n    }\n    animate(backdrop, 1, LIGHTBOX.fadeIn);\n    animate(chrome, 1, { ...LIGHTBOX.fadeIn, delay: 0.12 });\n\n    const state = live.current;\n    return () => hideTile(state, entries, null, reduceMotion);\n  }, [session, slides, origin, reduceMotion, entries, pose, backdrop, chrome]);\n\n  // Follow the window size; keep the current photo centered.\n  React.useLayoutEffect(() => {\n    const dialog = dialogRef.current;\n    if (!dialog) return;\n    const observer = new ResizeObserver(() => {\n      const width = dialog.clientWidth;\n      const height = dialog.clientHeight;\n      if (!width || !height) return;\n      setViewport((prev) => (prev.width === width && prev.height === height ? prev : { width, height }));\n    });\n    observer.observe(dialog);\n    return () => observer.disconnect();\n  }, []);\n\n  React.useLayoutEffect(() => {\n    track.jump(-live.current.index * pitch);\n  }, [pitch, track]);\n\n  // Landed: show the tile, close the dialog and hand focus to the tile, all in\n  // one task so no frame shows both or neither. Also runs if the browser\n  // closes the dialog on its own (a second Esc).\n  const finish = () => {\n    const state = live.current;\n    if (state.done) return;\n    state.done = true;\n    hideTile(state, entries, null, reduceMotion);\n    dialogRef.current?.close();\n    entries.get(slides[state.index].id)?.button?.focus({ preventScroll: true });\n    onClosed();\n  };\n\n  const close = (velocity = { x: 0, y: 0 }) => {\n    const state = live.current;\n    if (state.done) return;\n    state.closing = true;\n    state.drag = null;\n    setImmersive(false);\n    animate(hint, 0, LIGHTBOX.fadeOut);\n    const generation = ++state.generation;\n    const target = reduceMotion\n      ? null\n      : tilePose(entries.get(slides[state.index].id), boxOf(state.index), viewport);\n\n    animate(chrome, 0, LIGHTBOX.fadeOut);\n    animate(shade, 0, LIGHTBOX.fadeOut);\n    const runs = [\n      animate(backdrop, 0, { duration: 0.32, ease: \"easeOut\" }),\n      animate(track, -state.index * pitch, LIGHTBOX.page),\n    ];\n    if (target) {\n      runs.push(\n        animate(x, target.x, { ...LIGHTBOX.home, velocity: velocity.x }),\n        animate(y, target.y, { ...LIGHTBOX.home, velocity: velocity.y }),\n        animate(scale, target.scale, { ...LIGHTBOX.home, restDelta: 0.001 }),\n        animate(top, target.top, LIGHTBOX.home),\n        animate(right, target.right, LIGHTBOX.home),\n        animate(bottom, target.bottom, LIGHTBOX.home),\n        animate(left, target.left, LIGHTBOX.home),\n        animate(radius, target.radius, LIGHTBOX.home),\n        animate(opacity, 1, LIGHTBOX.fadeOut),\n      );\n    } else {\n      // No tile on screen to return to: fade out where it is.\n      runs.push(animate(opacity, 0, LIGHTBOX.fadeOut));\n      if (!reduceMotion) runs.push(animate(scale, scale.get() * 0.94, LIGHTBOX.fadeOut));\n    }\n    Promise.all(runs).then(() => {\n      if (live.current.generation === generation) finish();\n    });\n  };\n\n  const settle = (velocity = { x: 0, y: 0 }) => {\n    const spring = LIGHTBOX.settle;\n    animate(x, 0, { ...spring, velocity: velocity.x });\n    animate(y, 0, { ...spring, velocity: velocity.y });\n    animate(scale, 1, { ...spring, restDelta: 0.001 });\n    for (const value of [top, right, bottom, left, radius]) animate(value, 0, spring);\n    animate(opacity, 1, LIGHTBOX.fadeIn);\n    animate(backdrop, 1, LIGHTBOX.fadeIn);\n    if (!immersive) animate(chrome, 1, LIGHTBOX.fadeIn);\n  };\n\n  const go = (next: number, velocity = 0, fromGesture = false) => {\n    const state = live.current;\n    if (state.closing) return;\n    const target = clamp(next, 0, count - 1);\n    if (target !== state.index) {\n      state.index = target;\n      setIndex(target);\n      hideTile(state, entries, slides[target].id, reduceMotion);\n      // Bring its tile into view behind the viewer, so it has somewhere to land.\n      entries\n        .get(slides[target].id)\n        ?.button?.scrollIntoView({ block: \"nearest\", inline: \"nearest\", behavior: \"instant\" });\n      for (const slide of slides.slice(Math.max(target - 1, 0), target + 2)) preload(slide.src);\n    }\n    const settled = () => {\n      if (live.current.index === target) setAnchor(target);\n    };\n    if (reduceMotion && !fromGesture) {\n      track.jump(-target * pitch);\n      settled();\n      return;\n    }\n    animate(\n      track,\n      -target * pitch,\n      reduceMotion ? LIGHTBOX.fadeOut : { ...LIGHTBOX.page, velocity },\n    ).then(settled);\n  };\n\n  const toggleChrome = (source: HintSource) => {\n    const next = !immersive;\n    setImmersive(next);\n    animate(shade, next ? 1 : 0, { duration: 0.35, ease: \"easeInOut\" });\n    animate(chrome, next ? 0 : 1, next ? LIGHTBOX.fadeOut : LIGHTBOX.fadeIn);\n    if (next) {\n      // Keys keep working once the controls (and maybe the focused one) are gone.\n      stageRef.current?.focus({ preventScroll: true });\n      setHintText(HINTS[source]);\n      // Waits for the screen to darken, holds about a second and a half, fades.\n      animate(hint, [0, 0, 1, 1, 0], { duration: 2.4, times: [0, 0.12, 0.22, 0.8, 1] });\n    } else {\n      animate(hint, 0, LIGHTBOX.fadeOut);\n    }\n  };\n\n  // ── Gestures ────────────────────────────────────────────\n\n  const beginPage = (drag: Drag, event: React.PointerEvent) => {\n    track.stop();\n    drag.mode = \"page\";\n    drag.from = live.current.index;\n    drag.trackFrom = track.get();\n    drag.startX = event.clientX;\n  };\n\n  const beginLift = (drag: Drag, event: React.PointerEvent) => {\n    const i = live.current.index;\n    const box = boxOf(i);\n    for (const value of [x, y, scale, radius]) value.stop();\n    drag.mode = \"lift\";\n    drag.centerX = box.x + box.width / 2 + i * pitch + track.get();\n    drag.centerY = box.y + box.height / 2;\n    drag.scaleFrom = scale.get();\n    // The point under the finger, in the photo's own px from its center, so\n    // it stays under the finger however much the photo shrinks.\n    drag.grabX = (event.clientX - drag.centerX - x.get()) / drag.scaleFrom;\n    drag.grabY = (event.clientY - drag.centerY - y.get()) / drag.scaleFrom;\n    drag.startX = event.clientX;\n    drag.startY = event.clientY;\n    backdrop.stop();\n    drag.backdropFrom = backdrop.get();\n    for (const value of [top, right, bottom, left]) animate(value, 0, LIGHTBOX.settle);\n    animate(opacity, 1, LIGHTBOX.fadeIn);\n    animate(chrome, 0, LIGHTBOX.fadeOut);\n  };\n\n  const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {\n    const state = live.current;\n    if (state.done || state.drag || (event.pointerType === \"mouse\" && event.button !== 0)) return;\n\n    const onPhoto = hitsPhoto(event, stageRef.current?.querySelector(\"[data-current]\") ?? null, pose);\n    const caught = state.closing && onPhoto;\n    if (state.closing && !caught) return;\n\n    const drag: Drag = {\n      pointer: event.pointerId,\n      mode: \"pending\",\n      onPhoto,\n      startX: event.clientX,\n      startY: event.clientY,\n      samples: [{ x: event.clientX, y: event.clientY, t: event.timeStamp }],\n      from: state.index,\n      trackFrom: 0,\n      centerX: 0,\n      centerY: 0,\n      grabX: 0,\n      grabY: 0,\n      scaleFrom: 1,\n      backdropFrom: 1,\n    };\n    state.drag = drag;\n    event.currentTarget.setPointerCapture(event.pointerId);\n\n    if (caught) {\n      // Caught on its way home: it's the user's again.\n      state.closing = false;\n      state.generation++;\n      beginLift(drag, event);\n    } else if (Math.abs(track.get() + state.index * pitch) > 1) {\n      // Pressed while paging: catch the track and keep paging.\n      beginPage(drag, event);\n    }\n  };\n\n  const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {\n    const drag = live.current.drag;\n    if (!drag || event.pointerId !== drag.pointer) return;\n    record(drag, event);\n\n    if (drag.mode === \"pending\") {\n      const dx = event.clientX - drag.startX;\n      const dy = event.clientY - drag.startY;\n      if (Math.hypot(dx, dy) < LIGHTBOX.lock) return;\n      if (count > 1 && Math.abs(dx) > Math.abs(dy)) beginPage(drag, event);\n      else beginLift(drag, event);\n    }\n\n    if (drag.mode === \"page\") {\n      const min = -Math.min(drag.from + 1, count - 1) * pitch;\n      const max = -Math.max(drag.from - 1, 0) * pitch;\n      const raw = drag.trackFrom + event.clientX - drag.startX;\n      track.set(\n        raw > max\n          ? max + rubberband(raw - max, viewport.width)\n          : raw < min\n            ? min - rubberband(min - raw, viewport.width)\n            : raw,\n      );\n      return;\n    }\n\n    const height = viewport.height;\n    const dy = event.clientY - drag.startY;\n    const down = Math.max(dy, 0);\n    const size = drag.scaleFrom * Math.max(LIGHTBOX.minScale, 1 - down / (height * LIGHTBOX.shrink));\n    // Up is the wrong way: let it give a little, then resist.\n    const pointerY = dy < 0 ? drag.startY - rubberband(-dy, height) : event.clientY;\n    x.set(event.clientX - drag.grabX * size - drag.centerX);\n    y.set(pointerY - drag.grabY * size - drag.centerY);\n    scale.set(size);\n    radius.set(LIGHTBOX.lift * Math.min(down / 80, 1));\n    backdrop.set(drag.backdropFrom * (1 - Math.min(down / (height * LIGHTBOX.reveal), 1)));\n  };\n\n  const onPointerUp = (event: React.PointerEvent<HTMLDivElement>) => {\n    const state = live.current;\n    const drag = state.drag;\n    if (!drag || event.pointerId !== drag.pointer) return;\n    state.drag = null;\n    const cancelled = event.type === \"pointercancel\";\n    if (!cancelled) record(drag, event);\n    const velocity = cancelled ? { x: 0, y: 0 } : velocityOf(drag.samples, event.timeStamp);\n\n    if (drag.mode === \"pending\") {\n      if (cancelled) return;\n      if (drag.onPhoto) toggleChrome(event.pointerType === \"mouse\" ? \"mouse\" : \"touch\");\n      else close();\n      return;\n    }\n\n    if (drag.mode === \"page\") {\n      const position = -track.get() / pitch;\n      const next =\n        Math.abs(velocity.x) > LIGHTBOX.flick\n          ? velocity.x < 0\n            ? Math.floor(position) + 1\n            : Math.ceil(position) - 1\n          : Math.round(-(track.get() + project(velocity.x)) / pitch);\n      go(clamp(next, drag.from - 1, drag.from + 1), velocity.x, true);\n      return;\n    }\n\n    // Decide by where the throw was heading, not where the finger stopped.\n    const dy = event.clientY - drag.startY;\n    const threshold = Math.max(96, viewport.height * LIGHTBOX.dismiss);\n    const home =\n      !cancelled &&\n      velocity.y > -50 &&\n      (velocity.y > LIGHTBOX.flick || dy + project(velocity.y) > threshold);\n    if (home) close(velocity);\n    else settle(velocity);\n  };\n\n  const onKeyDown = (event: React.KeyboardEvent<HTMLDialogElement>) => {\n    const state = live.current;\n    if (event.key.toLowerCase() === \"h\" && !event.metaKey && !event.ctrlKey && !event.altKey) {\n      event.preventDefault();\n      if (!state.closing) toggleChrome(\"key\");\n      return;\n    }\n    const keys: Record<string, number> = {\n      ArrowLeft: state.index - 1,\n      ArrowRight: state.index + 1,\n      Home: 0,\n      End: count - 1,\n    };\n    if (event.key in keys) {\n      event.preventDefault();\n      go(keys[event.key]);\n    } else if (\n      [\"ArrowUp\", \"ArrowDown\", \"PageUp\", \"PageDown\", \" \"].includes(event.key) &&\n      !(event.target instanceof HTMLButtonElement)\n    ) {\n      event.preventDefault(); // don't scroll the page behind\n    }\n  };\n\n  // Trackpad swipes page; nothing scrolls the page behind.\n  const onWheel = React.useRef<(event: WheelEvent) => void>(() => {});\n  React.useLayoutEffect(() => {\n    onWheel.current = (event) => {\n      event.preventDefault();\n      const wheel = live.current.wheel;\n      window.clearTimeout(wheel.timer);\n      wheel.timer = window.setTimeout(() => {\n        wheel.sum = 0;\n        wheel.locked = false;\n      }, 180);\n      if (wheel.locked || count < 2 || Math.abs(event.deltaX) <= Math.abs(event.deltaY)) return;\n      wheel.sum += event.deltaX;\n      if (Math.abs(wheel.sum) > 40) {\n        wheel.locked = true;\n        go(live.current.index + Math.sign(wheel.sum));\n      }\n    };\n  });\n  React.useLayoutEffect(() => {\n    const dialog = dialogRef.current;\n    if (!dialog) return;\n    const listener = (event: WheelEvent) => onWheel.current(event);\n    dialog.addEventListener(\"wheel\", listener, { passive: false });\n    return () => dialog.removeEventListener(\"wheel\", listener);\n  }, []);\n\n  const first = Math.max(Math.min(index, anchor) - 1, 0);\n  const last = Math.min(Math.max(index, anchor) + 1, count - 1);\n  const current = slides[index];\n  const arrow = cn(\n    \"absolute top-1/2 -translate-y-1/2 disabled:pointer-events-none disabled:opacity-0\",\n    \"max-sm:hidden pointer-coarse:hidden\", // touch swipes instead\n  );\n  const control = cn(\n    \"grid size-10 place-items-center rounded-full bg-foreground/[0.06] text-foreground backdrop-blur-md outline-none\",\n    \"transition-[background-color,scale,opacity] duration-150 ease-out hover:bg-foreground/10 active:scale-95\",\n    \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground\",\n    immersive ? \"pointer-events-none\" : \"pointer-events-auto\",\n  );\n\n  return (\n    <dialog\n      ref={dialogRef}\n      aria-label=\"Photo viewer\"\n      onCancel={(event) => {\n        event.preventDefault();\n        close();\n      }}\n      onClose={finish}\n      onKeyDown={onKeyDown}\n      className=\"fixed inset-0 m-0 size-full max-h-none max-w-none overflow-hidden border-0 bg-transparent p-0 text-foreground outline-none backdrop:bg-transparent\"\n    >\n      <motion.div\n        aria-hidden\n        className={cn(\"absolute inset-0 bg-background\", overlayClassName)}\n        style={{ opacity: backdrop }}\n      />\n      <motion.div aria-hidden className=\"absolute inset-0 bg-black\" style={{ opacity: shadeOpacity }} />\n\n      <div\n        ref={stageRef}\n        tabIndex={-1}\n        className=\"absolute inset-0 cursor-zoom-out touch-none overscroll-none outline-none select-none\"\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={onPointerUp}\n        onPointerCancel={onPointerUp}\n      >\n        <motion.div className=\"absolute inset-0\" style={{ x: track }}>\n          {range(first, last).map((i) => (\n            <Photo\n              key={slides[i].id}\n              slide={slides[i]}\n              box={boxOf(i)}\n              offset={i * pitch}\n              width={viewport.width}\n              pose={i === index ? { ...pose, clipPath } : undefined}\n              onAspect={(aspect) =>\n                setAspects((prev) =>\n                  Math.abs((prev[slides[i].id] ?? slides[i].aspect) - aspect) < 0.01\n                    ? prev\n                    : { ...prev, [slides[i].id]: aspect },\n                )\n              }\n            />\n          ))}\n        </motion.div>\n      </div>\n\n      <motion.div\n        className=\"pointer-events-none absolute inset-0\"\n        style={{ opacity: chrome }}\n        // Tabbing to a hidden control brings the controls back.\n        onFocus={(event) => {\n          if (immersive && event.target.matches(\":focus-visible\")) toggleChrome(\"key\");\n        }}\n      >\n        <div className=\"absolute inset-x-0 top-0 flex items-center justify-between gap-4 p-3 sm:p-4\">\n          <p\n            aria-live=\"polite\"\n            className={cn(\n              \"rounded-full bg-foreground/[0.06] px-3 py-1.5 font-mono text-xs text-muted-foreground tabular-nums backdrop-blur-md\",\n              count < 2 && \"invisible\",\n            )}\n          >\n            {index + 1} / {count}\n          </p>\n          <div className=\"relative flex items-center gap-2\">\n            <button\n              type=\"button\"\n              aria-label=\"Hide controls\"\n              aria-keyshortcuts=\"H\"\n              aria-describedby={tooltipId}\n              className={cn(control, \"peer\")}\n              onClick={(event) => toggleChrome(sourceOf(event))}\n            >\n              <Icon path=\"M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z\" />\n            </button>\n            <div\n              id={tooltipId}\n              role=\"tooltip\"\n              className={cn(\n                \"pointer-events-none absolute top-full right-12 mt-2 w-max rounded-lg bg-foreground px-2.5 py-1.5 text-xs text-background opacity-0 shadow-lg\",\n                \"transition-opacity duration-150 peer-hover:opacity-100 peer-hover:delay-500 peer-focus-visible:opacity-100 pointer-coarse:hidden\",\n              )}\n            >\n              <span className=\"flex items-center gap-2 font-medium\">\n                Hide controls\n                <kbd className=\"rounded bg-background/15 px-1 font-mono text-[10px] leading-4\">H</kbd>\n              </span>\n              <span className=\"mt-0.5 block text-background/60\">or click the photo</span>\n            </div>\n            <button\n              type=\"button\"\n              aria-label=\"Close\"\n              className={control}\n              onClick={() => close()}\n            >\n              <Icon path=\"M6 6l12 12M18 6L6 18\" />\n            </button>\n          </div>\n        </div>\n\n        {count > 1 ? (\n          <>\n            <button\n              type=\"button\"\n              aria-label=\"Previous photo\"\n              disabled={index === 0}\n              className={cn(control, arrow, \"left-5\")}\n              onClick={() => go(index - 1)}\n            >\n              <Icon path=\"M15 6l-6 6 6 6\" />\n            </button>\n            <button\n              type=\"button\"\n              aria-label=\"Next photo\"\n              disabled={index === count - 1}\n              className={cn(control, arrow, \"right-5\")}\n              onClick={() => go(index + 1)}\n            >\n              <Icon path=\"M9 6l6 6-6 6\" />\n            </button>\n          </>\n        ) : null}\n\n        {current.caption ? (\n          <div className=\"absolute inset-x-0 bottom-0 flex justify-center px-6 pb-4 sm:pb-6\">\n            <motion.p\n              key={current.id}\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1 }}\n              transition={LIGHTBOX.fadeIn}\n              className=\"line-clamp-2 max-w-prose text-center text-sm text-pretty text-muted-foreground\"\n            >\n              {current.caption}\n            </motion.p>\n          </div>\n        ) : null}\n      </motion.div>\n\n      {hintText ? (\n        <motion.p\n          aria-live=\"polite\"\n          className=\"pointer-events-none absolute inset-x-4 bottom-6 mx-auto w-fit rounded-full bg-white/10 px-3.5 py-1.5 text-center text-xs text-white/75 backdrop-blur-md\"\n          style={{ opacity: hint }}\n        >\n          {hintText}\n        </motion.p>\n      ) : null}\n    </dialog>\n  );\n}\n\nconst STILL = { x: 0, y: 0, scale: 1, opacity: 1, clipPath: \"none\" };\n\nfunction Photo({\n  slide,\n  box,\n  offset,\n  width,\n  pose,\n  onAspect,\n}: {\n  slide: Slide;\n  box: Box;\n  offset: number;\n  width: number;\n  pose?: {\n    x: MotionValue<number>;\n    y: MotionValue<number>;\n    scale: MotionValue<number>;\n    opacity: MotionValue<number>;\n    clipPath: MotionValue<string>;\n  };\n  onAspect: (aspect: number) => void;\n}) {\n  const [loaded, setLoaded] = React.useState(false);\n  const layered = Boolean(slide.thumbnail && slide.thumbnail !== slide.src);\n  const style = pose\n    ? { x: pose.x, y: pose.y, scale: pose.scale, opacity: pose.opacity, clipPath: pose.clipPath }\n    : STILL;\n\n  return (\n    <div className=\"absolute inset-y-0\" style={{ left: offset, width }}>\n      <motion.div\n        data-current={pose ? \"\" : undefined}\n        className=\"absolute cursor-grab will-change-transform active:cursor-grabbing\"\n        style={{ left: box.x, top: box.y, width: box.width, height: box.height, ...style }}\n      >\n        {layered ? (\n          <img\n            src={slide.thumbnail}\n            alt=\"\"\n            aria-hidden\n            draggable={false}\n            className=\"absolute inset-0 size-full object-cover\"\n          />\n        ) : null}\n        <img\n          src={slide.src}\n          alt={slide.alt}\n          decoding=\"async\"\n          draggable={false}\n          onLoad={(event) => {\n            const image = event.currentTarget;\n            setLoaded(true);\n            if (image.naturalWidth && image.naturalHeight) {\n              onAspect(image.naturalWidth / image.naturalHeight);\n            }\n          }}\n          className={cn(\n            \"absolute inset-0 size-full object-cover transition-opacity duration-300\",\n            layered && !loaded && \"opacity-0\",\n          )}\n        />\n      </motion.div>\n    </div>\n  );\n}\n\nfunction Icon({ path }: { path: string }) {\n  return (\n    <svg\n      aria-hidden\n      viewBox=\"0 0 24 24\"\n      className=\"size-[18px]\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={1.75}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n    >\n      <path d={path} />\n    </svg>\n  );\n}\n\n/** What pressed a button: a keyboard (no pointer), a finger or pen, or a mouse. */\nfunction sourceOf(event: React.MouseEvent): HintSource {\n  if (event.detail === 0) return \"key\";\n  const type = (event.nativeEvent as Partial<PointerEvent>).pointerType;\n  return type === \"touch\" || type === \"pen\" ? \"touch\" : \"mouse\";\n}\n\nfunction useLightbox() {\n  const context = React.useContext(LightboxContext);\n  if (!context) throw new Error(\"ZoomLightboxItem must be used inside <ZoomLightbox>.\");\n  return context;\n}\n\n// ── Geometry ──────────────────────────────────────────────\n\nfunction readViewport(): Viewport {\n  return {\n    width: document.documentElement.clientWidth,\n    height: document.documentElement.clientHeight,\n  };\n}\n\n/** The largest box with this aspect ratio that fits the screen, centered. */\nfunction fit(aspect: number, viewport: Viewport): Box {\n  const inset = viewport.width < FRAME.narrow ? FRAME.inset : FRAME.wideInset;\n  const maxWidth = Math.max(viewport.width - inset.x * 2, 1);\n  const maxHeight = Math.max(viewport.height - inset.y * 2, 1);\n  const width = Math.min(maxWidth, maxHeight * aspect);\n  const height = width / aspect;\n  return { x: (viewport.width - width) / 2, y: (viewport.height - height) / 2, width, height };\n}\n\n/**\n * The pose that makes the photo, laid out in `box`, look exactly like its\n * tile: scaled to cover the tile's image, cropped to the tile, with the\n * tile's corners. Null when the tile isn't on screen.\n */\nfunction tilePose(entry: Entry | undefined, box: Box, viewport: Viewport): Pose | null {\n  if (!entry?.button || !entry.image) return null;\n  const tile = entry.button.getBoundingClientRect();\n  const visible =\n    tile.width > 0 &&\n    tile.height > 0 &&\n    tile.bottom > 0 &&\n    tile.right > 0 &&\n    tile.top < viewport.height &&\n    tile.left < viewport.width;\n  if (!visible) return null;\n\n  // The tile's image may be scaled on hover; cover it the way object-cover does.\n  const image = entry.image.getBoundingClientRect();\n  const scale = Math.max(image.width / box.width, image.height / box.height);\n  const centerX = image.left + image.width / 2;\n  const centerY = image.top + image.height / 2;\n  const photoLeft = centerX - (box.width * scale) / 2;\n  const photoTop = centerY - (box.height * scale) / 2;\n\n  return {\n    x: centerX - (box.x + box.width / 2),\n    y: centerY - (box.y + box.height / 2),\n    scale,\n    top: Math.max(tile.top - photoTop, 0) / scale,\n    left: Math.max(tile.left - photoLeft, 0) / scale,\n    bottom: Math.max(photoTop + box.height * scale - tile.bottom, 0) / scale,\n    right: Math.max(photoLeft + box.width * scale - tile.right, 0) / scale,\n    radius: parseFloat(getComputedStyle(entry.button).borderTopLeftRadius) || 0,\n  };\n}\n\n/** Whether a press landed on the visible part of the photo. */\nfunction hitsPhoto(\n  event: React.PointerEvent,\n  element: HTMLElement | null,\n  pose: Record<\"scale\" | \"top\" | \"right\" | \"bottom\" | \"left\", MotionValue<number>>,\n) {\n  if (!element) return false;\n  const rect = element.getBoundingClientRect();\n  const s = pose.scale.get();\n  return (\n    event.clientX >= rect.left + pose.left.get() * s &&\n    event.clientX <= rect.right - pose.right.get() * s &&\n    event.clientY >= rect.top + pose.top.get() * s &&\n    event.clientY <= rect.bottom - pose.bottom.get() * s\n  );\n}\n\nfunction aspectOf(entry: Entry) {\n  if (entry.width && entry.height) return entry.width / entry.height;\n  const full = preloaded.get(entry.src);\n  if (full?.naturalWidth) return full.naturalWidth / full.naturalHeight;\n  if (entry.image?.naturalWidth) return entry.image.naturalWidth / entry.image.naturalHeight;\n  const tile = entry.button?.getBoundingClientRect();\n  return tile?.height ? tile.width / tile.height : 1;\n}\n\n// ── Helpers ───────────────────────────────────────────────\n\nconst preloaded = new Map<string, HTMLImageElement>();\n\nfunction preload(src: string) {\n  if (preloaded.has(src)) return;\n  const image = new Image();\n  image.decoding = \"async\";\n  image.src = src;\n  preloaded.set(src, image);\n}\n\n/** Hides the tile whose photo is open, so the photo reads as the same object. */\nfunction hideTile(\n  state: { hidden: HTMLElement | null },\n  entries: Map<string, Entry>,\n  id: string | null,\n  reduceMotion: boolean,\n) {\n  if (state.hidden) state.hidden.style.opacity = \"\";\n  state.hidden = null;\n  // With reduced motion the photo fades instead of flying, so the tile stays.\n  const image = id && !reduceMotion ? entries.get(id)?.image : null;\n  if (!image) return;\n  image.style.opacity = \"0\";\n  state.hidden = image;\n}\n\nfunction record(drag: Drag, event: React.PointerEvent) {\n  drag.samples.push({ x: event.clientX, y: event.clientY, t: event.timeStamp });\n  while (drag.samples.length > 2 && event.timeStamp - drag.samples[0].t > 100) drag.samples.shift();\n}\n\n/** Pointer velocity over the last 80ms, in px/s. Zero if the finger had stopped. */\nfunction velocityOf(samples: Drag[\"samples\"], now: number) {\n  const last = samples[samples.length - 1];\n  if (!last || now - last.t > 80) return { x: 0, y: 0 };\n  const first = samples.find((sample) => last.t - sample.t <= 80) ?? last;\n  const dt = (last.t - first.t) / 1000;\n  if (dt <= 0) return { x: 0, y: 0 };\n  const cap = (v: number) => clamp(v, -4000, 4000);\n  return { x: cap((last.x - first.x) / dt), y: cap((last.y - first.y) / dt) };\n}\n\n/** How much further a flick travels before it comes to rest, like a scroll view. */\nfunction project(velocity: number, rate = 0.99) {\n  return ((velocity / 1000) * rate) / (1 - rate);\n}\n\n/** Past a boundary, follow less and less the further it's pulled. */\nfunction rubberband(overshoot: number, dimension: number, constant = 0.55) {\n  return (overshoot * dimension * constant) / (dimension + constant * overshoot);\n}\n\nfunction clamp(value: number, min: number, max: number) {\n  return Math.min(Math.max(value, min), max);\n}\n\nfunction range(from: number, to: number) {\n  return Array.from({ length: Math.max(to - from + 1, 0) }, (_, i) => from + i);\n}\n"
    }
  ]
}