{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "lightbox",
  "type": "registry:ui",
  "title": "Lightbox",
  "description": "Photos lift out of their thumbnails, swipe between, zoom, and land back where they came from.",
  "categories": [
    "media"
  ],
  "dependencies": [
    "@base-ui/react",
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json",
    "https://www.stealth.pm/r/zoom-pan-image.json"
  ],
  "files": [
    {
      "path": "components/ui/lightbox.tsx",
      "content": "\"use client\";\nimport { Dialog } from \"@base-ui/react/dialog\";\nimport NumberFlow from \"@number-flow/react\";\nimport { animate, motion, useMotionValue, useReducedMotion, useTransform, type AnimationPlaybackControls } from \"motion/react\";\nimport { createContext, use, useCallback, useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ChevronLeft, ChevronRight, X, ZoomIn, ZoomOut } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\nimport { useZoomPan } from \"@/components/ui/zoom-pan-image\";\n\nexport type LightboxImage = {\n  src: string;\n  alt: string;\n  /** Intrinsic size, so the image is fitted and the flight is planned before it loads. */\n  width: number;\n  height: number;\n  /** A smaller file already on the page. Shown instantly while `src` loads. Defaults to `src`. */\n  thumb?: string;\n  caption?: React.ReactNode;\n};\n\ntype Phase = \"closed\" | \"opening\" | \"open\" | \"closing\";\ntype Rect = { left: number; top: number; width: number; height: number; radius: number };\n\ntype Ctx = {\n  images: LightboxImage[];\n  index: number;\n  phase: Phase;\n  openAt: (i: number, from: HTMLElement) => void;\n  register: (i: number, el: HTMLElement | null) => void;\n};\nconst LightboxContext = createContext<Ctx | null>(null);\nconst useLightbox = () => {\n  const ctx = use(LightboxContext);\n  if (!ctx) throw new Error(\"Lightbox parts must be inside <Lightbox>\");\n  return ctx;\n};\n\nconst clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));\nconst band = (over: number, size: number) => (over * size * 0.55) / (size + 0.55 * over);\n\nfunction rectOf(el: HTMLElement): Rect {\n  const r = el.getBoundingClientRect();\n  return { left: r.left, top: r.top, width: r.width, height: r.height, radius: parseFloat(getComputedStyle(el).borderTopLeftRadius) || 0 };\n}\n\nexport type LightboxProps = {\n  images: LightboxImage[];\n  index?: number;\n  defaultIndex?: number;\n  onIndexChange?: (index: number) => void;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /**\n   * Render the viewer inside this element instead of over the whole page. The element needs\n   * position: relative and overflow: hidden; page scroll isn't locked.\n   */\n  container?: HTMLElement | null;\n  /**\n   * Finds the on-page image for an index when the thumbnails aren't LightboxTriggers (a gallery\n   * grid, say), so the viewer can still fly out of and back into them.\n   */\n  getThumbnail?: (index: number) => HTMLElement | null;\n  /** Accessible label of the viewer. Defaults to \"Photo 3 of 8\". */\n  getLabel?: (index: number, total: number) => string;\n  children: React.ReactNode;\n};\n\nexport function Lightbox({\n  images,\n  index: indexProp,\n  defaultIndex = 0,\n  onIndexChange,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  container,\n  getThumbnail,\n  getLabel = (i, n) => `Photo ${i + 1} of ${n}`,\n  children,\n}: LightboxProps) {\n  const [index, setIndex] = useControllableState({ value: indexProp, defaultValue: defaultIndex, onChange: onIndexChange });\n  const [open, setOpen] = useControllableState({ value: openProp, defaultValue: defaultOpen, onChange: onOpenChange });\n  const thumbs = useRef(new Map<number, HTMLElement>());\n  // The phase runs a step behind `open`: the viewer stays mounted while it flies back.\n  const [phase, setPhase] = useState<Phase>(open ? \"open\" : \"closed\");\n  const [from, setFrom] = useState<Rect | null>(null);\n  const [instant, setInstant] = useState(false);\n  const [prevOpen, setPrevOpen] = useState(open);\n\n  // An open or close requested from outside (controlled) still flies.\n  if (prevOpen !== open) {\n    setPrevOpen(open);\n    if (open && (phase === \"closed\" || phase === \"closing\")) {\n      setFrom(null);\n      setPhase(\"opening\");\n    }\n    if (!open && (phase === \"open\" || phase === \"opening\")) setPhase(\"closing\");\n  }\n\n  const register = useCallback((i: number, el: HTMLElement | null) => {\n    if (el) thumbs.current.set(i, el);\n    else thumbs.current.delete(i);\n  }, []);\n\n  const openAt = useCallback(\n    (i: number, el: HTMLElement) => {\n      setFrom(rectOf(el));\n      setInstant(false);\n      setIndex(i);\n      setPhase(\"opening\");\n      setOpen(true);\n    },\n    [setIndex, setOpen],\n  );\n\n  const requestClose = useCallback(\n    (quick = false) => {\n      setInstant(quick);\n      setPhase((p) => (p === \"closed\" ? p : \"closing\"));\n      setOpen(false);\n    },\n    [setOpen],\n  );\n\n  const mounted = phase !== \"closed\";\n\n  return (\n    <LightboxContext value={{ images, index, phase, openAt, register }}>\n      <Dialog.Root\n        open={mounted}\n        modal={container ? \"trap-focus\" : true}\n        onOpenChange={(next) => {\n          if (!next) requestClose();\n        }}\n      >\n        {children}\n        {mounted && (\n          <Dialog.Portal container={container}>\n            <Viewer\n              images={images}\n              index={index}\n              setIndex={setIndex}\n              phase={phase}\n              from={from}\n              instant={instant}\n              contained={container != null}\n              label={getLabel(index, images.length)}\n              thumbFor={(i) => thumbs.current.get(i) ?? getThumbnail?.(i) ?? null}\n              onOpened={() => setPhase((p) => (p === \"opening\" ? \"open\" : p))}\n              onClosed={() => setPhase((p) => (p === \"closing\" ? \"closed\" : p))}\n              requestClose={requestClose}\n            />\n          </Dialog.Portal>\n        )}\n      </Dialog.Root>\n    </LightboxContext>\n  );\n}\n\nexport type LightboxTriggerProps = Omit<React.ComponentProps<\"button\">, \"children\"> & {\n  index: number;\n  /** Classes for the thumbnail image. */\n  imgClassName?: string;\n};\n\n/** A thumbnail that opens the viewer. The image flies out of it, and back into it on close. */\nexport function LightboxTrigger({ index, className, imgClassName, onClick, ref, ...rest }: LightboxTriggerProps) {\n  const { images, index: current, phase, openAt, register } = useLightbox();\n  const image = images[index];\n  // While its image is up in the viewer, the thumbnail's slot stays empty: that's where it will land.\n  const lifted = phase !== \"closed\" && current === index;\n  return (\n    <button\n      type=\"button\"\n      aria-label={`View ${image.alt}`}\n      aria-haspopup=\"dialog\"\n      data-lifted={lifted || undefined}\n      ref={(el) => {\n        register(index, el);\n        if (typeof ref === \"function\") return ref(el);\n        if (ref) ref.current = el;\n      }}\n      onClick={(e) => {\n        onClick?.(e);\n        if (!e.defaultPrevented) openAt(index, e.currentTarget);\n      }}\n      className={cn(\n        \"group/thumb relative block overflow-hidden rounded-lg bg-hover\",\n        \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n        \"transition-[scale] duration-150 ease-out active:scale-[0.97] active:duration-75\",\n        \"after:absolute after:inset-0 after:rounded-[inherit] after:bg-page/0 after:transition-colors after:duration-150 hover:after:bg-page/15\",\n        className,\n      )}\n      {...rest}\n    >\n      {/* eslint-disable-next-line @next/next/no-img-element */}\n      <img\n        src={image.thumb ?? image.src}\n        alt=\"\"\n        draggable={false}\n        decoding=\"async\"\n        className={cn(\"size-full object-cover group-data-lifted/thumb:opacity-0\", imgClassName)}\n      />\n    </button>\n  );\n}\n\ntype ViewerProps = {\n  images: LightboxImage[];\n  index: number;\n  setIndex: (i: number) => void;\n  phase: Phase;\n  from: Rect | null;\n  instant: boolean;\n  contained: boolean;\n  label: string;\n  thumbFor: (i: number) => HTMLElement | null;\n  onOpened: () => void;\n  onClosed: () => void;\n  requestClose: (quick?: boolean) => void;\n};\n\nconst GAP = 24;\n\nfunction Viewer({ images, index, setIndex, phase, from, instant, contained, label, thumbFor, onOpened, onClosed, requestClose }: ViewerProps) {\n  const reduce = !!useReducedMotion();\n  const image = images[index];\n  const count = images.length;\n  const live = phase === \"open\";\n  // Lets a close drop a swipe that is still under the finger.\n  const cancelSwipe = useRef<() => void>(() => {});\n  const {\n    viewportRef,\n    contentRef,\n    x,\n    y,\n    scale,\n    zoomed,\n    reset,\n    toggle,\n    onKeyDown: zoomKeys,\n  } = useZoomPan({ max: 4, doubleTapScale: 2.5, wheel: \"modifier\", disabled: !live });\n  const track = useMotionValue(0);\n  const veil = useMotionValue(0);\n  // How far the image is cropped toward its thumbnail: insets in the content's own pixels, and a radius.\n  const crop = useMotionValue({ t: 0, r: 0, b: 0, l: 0, radius: 0 });\n  const clipPath = useTransform(() => {\n    const c = crop.get();\n    return c.t || c.r || c.b || c.l || c.radius ? `inset(${c.t}px ${c.r}px ${c.b}px ${c.l}px round ${c.radius}px)` : \"none\";\n  });\n  const chrome = useTransform(() => clamp((veil.get() - 0.6) / 0.4, 0, 1));\n  const flight = useRef<AnimationPlaybackControls | null>(null);\n  const [settledIndex, setSettledIndex] = useState(index);\n  // Where a pointer navigation is heading: the counter and caption change as the move starts, not after it.\n  const [heading, setHeading] = useState<{ from: number; to: number } | null>(null);\n  const shown = heading && heading.from === index ? heading.to : index;\n  const shownImage = images[shown];\n\n  // Plan a flight between the fitted image and a thumbnail rect (client px). Returns the values\n  // that make the content sit exactly over the thumbnail, cropped like object-fit: cover.\n  const plan = useCallback(\n    (target: Rect) => {\n      const v = viewportRef.current;\n      const c = contentRef.current;\n      if (!v || !c) return null;\n      const vr = v.getBoundingClientRect();\n      const R = { left: c.offsetLeft, top: c.offsetTop, w: c.offsetWidth, h: c.offsetHeight };\n      if (!R.w || !R.h) return null;\n      const T = { left: target.left - vr.left, top: target.top - vr.top, w: target.width, h: target.height };\n      const k = Math.max(T.w / R.w, T.h / R.h);\n      const cropX = (R.w - T.w / k) / 2;\n      const cropY = (R.h - T.h / k) / 2;\n      return {\n        s: k,\n        // Content is scaled from its top-left; place it so the visible (cropped) window lands on T.\n        x: T.left - R.left - cropX * k,\n        y: T.top - R.top - cropY * k,\n        crop: { t: cropY, r: cropX, b: cropY, l: cropX, radius: target.radius / k },\n        visible: T.left + T.w > 0 && T.top + T.h > 0 && T.left < vr.width && T.top < vr.height,\n      };\n    },\n    [viewportRef, contentRef],\n  );\n\n  // Drive x, y, scale, crop and the veil from one progress value, so they can never drift apart.\n  const fly = useCallback(\n    (to: { s: number; x: number; y: number; crop: { t: number; r: number; b: number; l: number; radius: number }; veil: number }, transition: object, done: () => void) => {\n      flight.current?.stop();\n      const f = { s: scale.get(), x: x.get(), y: y.get(), crop: crop.get(), veil: veil.get() };\n      const lerp = (a: number, b: number, t: number) => a + (b - a) * t;\n      flight.current = animate(0, 1, {\n        ...transition,\n        onUpdate: (t) => {\n          scale.set(lerp(f.s, to.s, t));\n          x.set(lerp(f.x, to.x, t));\n          y.set(lerp(f.y, to.y, t));\n          veil.set(clamp(lerp(f.veil, to.veil, t), 0, 1));\n          crop.set({\n            t: Math.max(0, lerp(f.crop.t, to.crop.t, t)),\n            r: Math.max(0, lerp(f.crop.r, to.crop.r, t)),\n            b: Math.max(0, lerp(f.crop.b, to.crop.b, t)),\n            l: Math.max(0, lerp(f.crop.l, to.crop.l, t)),\n            radius: Math.max(0, lerp(f.crop.radius, to.crop.radius, t)),\n          });\n        },\n        onComplete: done,\n      });\n    },\n    [scale, x, y, crop, veil],\n  );\n\n  const none = { t: 0, r: 0, b: 0, l: 0, radius: 0 };\n\n  // Opening: start exactly over the thumbnail, before the first paint, then fly out.\n  useLayoutEffect(() => {\n    if (phase !== \"opening\") return;\n    const el = from ? null : thumbFor(index);\n    const rect = from ?? (el ? rectOf(el) : null);\n    const p = rect && !reduce ? plan(rect) : null;\n    if (!p) {\n      scale.set(reduce ? 1 : 0.96);\n      veil.set(0);\n      fly({ s: 1, x: 0, y: 0, crop: none, veil: 1 }, { duration: reduce ? 0.15 : 0.22, ease: ease.out }, onOpened);\n      return;\n    }\n    scale.set(p.s);\n    x.set(p.x);\n    y.set(p.y);\n    crop.set(p.crop);\n    veil.set(0);\n    fly({ s: 1, x: 0, y: 0, crop: none, veil: 1 }, spring.soft, onOpened);\n    // Runs once per opening; the values it reads are fixed for that flight.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [phase]);\n\n  // Closing: back into the thumbnail of whichever image is showing now, from wherever it is.\n  useLayoutEffect(() => {\n    if (phase !== \"closing\") return;\n    cancelSwipe.current();\n    track.set(0);\n    const el = thumbFor(index);\n    const p = el && !reduce && !instant ? plan(rectOf(el)) : null;\n    if (!p || !p.visible) {\n      // The thumbnail is off screen (or motion is reduced): fade where it stands instead.\n      fly({ s: reduce || instant ? scale.get() : scale.get() * 0.96, x: x.get(), y: y.get(), crop: crop.get(), veil: 0 }, { duration: instant ? 0 : reduce ? 0.12 : 0.16, ease: ease.in }, onClosed);\n      return;\n    }\n    fly({ s: p.s, x: p.x, y: p.y, crop: p.crop, veil: 0 }, spring.snappy, onClosed);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [phase]);\n\n  useEffect(() => () => flight.current?.stop(), []);\n\n  // A new image starts unzoomed; the strip snaps back under it in the same frame.\n  if (settledIndex !== index) setSettledIndex(index);\n  useLayoutEffect(() => {\n    if (phase !== \"open\") return;\n    track.set(0);\n    reset({ instant: true });\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [settledIndex]);\n\n  const go = useCallback(\n    (dir: 1 | -1, how: \"pointer\" | \"key\") => {\n      const next = index + dir;\n      if (next < 0 || next >= count || phase !== \"open\") return;\n      const w = viewportRef.current?.clientWidth ?? 0;\n      if (how === \"key\" || reduce || !w) return setIndex(next);\n      flight.current?.stop();\n      setHeading({ from: index, to: next });\n      flight.current = animate(track, -dir * (w + GAP), { ...spring.snappy, onComplete: () => setIndex(next) });\n    },\n    [index, count, phase, reduce, setIndex, track, viewportRef],\n  );\n\n  // Single-pointer drags at rest: sideways to move between images, down (or up) to dismiss.\n  useEffect(() => {\n    const el = viewportRef.current;\n    if (!el || phase !== \"open\") return;\n    let start: { x: number; y: number; id: number } | null = null;\n    let axis: \"x\" | \"y\" | null = null;\n    let active = 0;\n    let startedOn: EventTarget | null = null;\n\n    const cancel = () => {\n      if (!start) return;\n      start = null;\n      axis = null;\n      animate(track, 0, spring.snappy);\n      animate(y, 0, spring.snappy);\n      animate(scale, 1, spring.snappy);\n      animate(veil, 1, { duration: 0.2 });\n    };\n    cancelSwipe.current = cancel;\n\n    const down = (e: PointerEvent) => {\n      active++;\n      if (active > 1 || scale.get() > 1.001 || (e.pointerType === \"mouse\" && e.button !== 0)) {\n        if (start) cancel();\n        return;\n      }\n      if ((e.target as HTMLElement).closest(\"button\")) return;\n      start = { x: e.clientX, y: e.clientY, id: e.pointerId };\n      startedOn = e.target;\n      axis = null;\n    };\n    const move = (e: PointerEvent) => {\n      if (!start || e.pointerId !== start.id) return;\n      const dx = e.clientX - start.x;\n      const dy = e.clientY - start.y;\n      if (!axis) {\n        if (Math.hypot(dx, dy) < 6) return;\n        axis = Math.abs(dx) > Math.abs(dy) ? \"x\" : \"y\";\n        el.setPointerCapture(e.pointerId);\n        flight.current?.stop();\n      }\n      const w = el.clientWidth;\n      const h = el.clientHeight;\n      if (axis === \"x\") {\n        const atEdge = (dx > 0 && index === 0) || (dx < 0 && index === count - 1);\n        track.set(atEdge ? Math.sign(dx) * band(Math.abs(dx), w) : dx);\n      } else {\n        // The image follows the finger 1:1 and shrinks a little; the veil thins as it goes.\n        const d = Math.abs(dy);\n        y.set(dy);\n        scale.set(1 - Math.min(d / h, 1) * 0.25);\n        veil.set(1 - Math.min(d / (h * 0.6), 1));\n      }\n    };\n    const up = (e: PointerEvent) => {\n      active = Math.max(0, active - 1);\n      if (!start || e.pointerId !== start.id) return;\n      const s = start;\n      start = null;\n      const dx = e.clientX - s.x;\n      const dy = e.clientY - s.y;\n      if (!axis) {\n        // A plain click on the dark around the image closes it.\n        if (e.type === \"pointerup\" && startedOn === el && !contentRef.current?.contains(e.target as Node)) requestClose();\n        return;\n      }\n      if (axis === \"x\") {\n        const v = track.getVelocity();\n        const w = el.clientWidth;\n        const dir = dx < 0 ? 1 : -1;\n        const next = index + dir;\n        if ((Math.abs(dx) > w * 0.2 || Math.abs(v) > 500) && Math.sign(v || dx) === -dir && next >= 0 && next < count) {\n          setHeading({ from: index, to: next });\n          flight.current = animate(track, -dir * (w + GAP), { ...spring.snappy, velocity: v, onComplete: () => setIndex(next) });\n        } else animate(track, 0, { ...spring.snappy, velocity: v });\n      } else {\n        const v = y.getVelocity();\n        if (Math.abs(dy) > 110 || Math.abs(v) > 700) requestClose();\n        else {\n          animate(y, 0, { ...spring.snappy, velocity: v });\n          animate(scale, 1, spring.snappy);\n          animate(veil, 1, { duration: 0.2, ease: ease.out });\n        }\n      }\n      axis = null;\n    };\n    el.addEventListener(\"pointerdown\", down);\n    el.addEventListener(\"pointermove\", move);\n    el.addEventListener(\"pointerup\", up);\n    el.addEventListener(\"pointercancel\", up);\n    return () => {\n      el.removeEventListener(\"pointerdown\", down);\n      el.removeEventListener(\"pointermove\", move);\n      el.removeEventListener(\"pointerup\", up);\n      el.removeEventListener(\"pointercancel\", up);\n      cancelSwipe.current = () => {};\n    };\n  }, [phase, index, count, viewportRef, contentRef, track, y, scale, veil, setIndex, requestClose]);\n\n  const slides = [index - 1, index, index + 1].filter((i) => i >= 0 && i < count);\n  const chromeHidden = phase !== \"open\";\n\n  return (\n    <>\n      <Dialog.Backdrop\n        render={<motion.div style={{ opacity: veil }} />}\n        className={cn(contained ? \"absolute\" : \"fixed\", \"inset-0 z-(--z-overlay) bg-page\")}\n      />\n      <Dialog.Popup\n        finalFocus={() => thumbFor(index) ?? true}\n        className={cn(contained ? \"absolute\" : \"fixed\", \"inset-0 z-(--z-dialog) outline-none\")}\n        onKeyDown={(e) => {\n          zoomKeys(e);\n          if (e.defaultPrevented) return;\n          if (e.key === \"ArrowRight\") go(1, \"key\");\n          else if (e.key === \"ArrowLeft\") go(-1, \"key\");\n          else return;\n          e.preventDefault();\n        }}\n      >\n        <div\n          ref={viewportRef}\n          data-zoomed={zoomed || undefined}\n          className=\"absolute inset-0 touch-none select-none overflow-hidden [container-type:size] [--lb-x:24px] sm:[--lb-x:128px]\"\n        >\n          <motion.div className=\"absolute inset-0\" style={{ x: track }}>\n            {slides.map((i) => {\n              const img = images[i];\n              const ratio = img.width / img.height;\n              const isActive = i === index;\n              return (\n                <div\n                  key={i}\n                  aria-hidden={!isActive || undefined}\n                  className=\"absolute inset-0\"\n                  style={{ transform: `translateX(calc(${(i - index) * 100}% + ${(i - index) * GAP}px))` }}\n                >\n                  <motion.div\n                    ref={isActive ? contentRef : undefined}\n                    // Fitted inside the space the chrome leaves, at the image's own ratio.\n                    className={cn(\n                      \"absolute inset-x-3 bottom-[76px] top-14 m-auto sm:inset-x-16\",\n                      isActive && live && (zoomed ? \"cursor-grab active:cursor-grabbing\" : \"cursor-zoom-in\"),\n                    )}\n                    style={{\n                      width: `min(100cqw - var(--lb-x), (100cqh - 132px) * ${ratio})`,\n                      height: `min(100cqh - 132px, (100cqw - var(--lb-x)) / ${ratio})`,\n                      ...(isActive ? { x, y, scale, originX: 0, originY: 0, clipPath } : {}),\n                    }}\n                  >\n                    <SlideImage image={img} priority={isActive} />\n                  </motion.div>\n                </div>\n              );\n            })}\n          </motion.div>\n        </div>\n\n        {/* Chrome: fades in once the image has landed, and out the moment it leaves. */}\n        <motion.div style={{ opacity: chrome }} className={cn(\"pointer-events-none absolute inset-0\", !chromeHidden && \"*:pointer-events-auto\")}>\n          <div className=\"absolute inset-x-0 top-0 flex h-14 items-center justify-between gap-3 px-3 sm:px-4\">\n            <Dialog.Title className=\"flex h-7 items-center gap-1 rounded-full border border-line-2 bg-raised/80 px-2.5 font-mono text-[11.5px] text-fg-2 shadow-[var(--shadow)] backdrop-blur-sm tabular\">\n              <span className=\"sr-only\">\n                {label}: {image.alt}\n              </span>\n              <NumberFlow aria-hidden value={shown + 1} animated={!reduce} className=\"text-fg\" />\n              <span aria-hidden className=\"text-fg-3\">\n                / {count}\n              </span>\n            </Dialog.Title>\n            <div className=\"flex items-center gap-1\">\n              <ChromeButton label={zoomed ? \"Zoom out\" : \"Zoom in\"} onClick={() => toggle()}>\n                {zoomed ? <ZoomOut /> : <ZoomIn />}\n              </ChromeButton>\n              <Dialog.Close\n                aria-label=\"Close\"\n                className={chromeButtonClass}\n              >\n                <X />\n              </Dialog.Close>\n            </div>\n          </div>\n\n          <NavButton side=\"left\" label=\"Previous photo\" disabled={index === 0} onClick={() => go(-1, \"pointer\")} />\n          <NavButton side=\"right\" label=\"Next photo\" disabled={index === count - 1} onClick={() => go(1, \"pointer\")} />\n\n          <div className=\"pointer-events-none! absolute inset-x-0 bottom-0 flex h-[76px] items-center justify-center px-14 sm:px-16\">\n            <p\n              key={shown}\n              className={cn(\n                \"line-clamp-2 max-w-[60ch] text-balance text-center text-[12.5px] leading-[18px] text-fg-2 transition-opacity duration-200 starting:opacity-0\",\n                // Out of the way while zoomed: the photo has the whole frame.\n                zoomed && \"opacity-0\",\n              )}\n            >\n              {shownImage.caption ?? shownImage.alt}\n            </p>\n          </div>\n        </motion.div>\n      </Dialog.Popup>\n    </>\n  );\n}\n\n// The thumbnail (already decoded on the page) shows at once; the full file fades in over it.\nfunction SlideImage({ image, priority }: { image: LightboxImage; priority: boolean }) {\n  const [loaded, setLoaded] = useState(false);\n  return (\n    <>\n      {image.thumb && (\n        // eslint-disable-next-line @next/next/no-img-element\n        <img src={image.thumb} alt=\"\" draggable={false} className=\"pointer-events-none absolute inset-0 size-full object-cover\" />\n      )}\n      {/* eslint-disable-next-line @next/next/no-img-element */}\n      <img\n        ref={(img) => {\n          if (img?.complete && img.naturalWidth) setLoaded(true);\n        }}\n        src={image.src}\n        alt=\"\"\n        draggable={false}\n        decoding=\"async\"\n        fetchPriority={priority ? \"high\" : undefined}\n        onLoad={() => setLoaded(true)}\n        className={cn(\n          \"pointer-events-none absolute inset-0 size-full object-cover transition-opacity duration-300 ease-out\",\n          loaded || !image.thumb ? \"opacity-100\" : \"opacity-0\",\n        )}\n      />\n    </>\n  );\n}\n\n// Chrome sits on its own small surfaces, so it stays legible over a zoomed photo in either theme.\nconst chromeButtonClass = cn(\n  \"relative grid size-8 place-items-center rounded-full border border-line-2 bg-raised/80 text-fg-2 shadow-[var(--shadow)] backdrop-blur-sm\",\n  \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n  \"transition-[background-color,color,scale] duration-150 hover:bg-hover hover:text-fg active:scale-[0.92] active:duration-75\",\n  \"before:absolute before:-inset-1.5 before:content-[''] pointer-fine:before:hidden\",\n);\n\nfunction ChromeButton({ label, children, ...rest }: React.ComponentProps<\"button\"> & { label: string }) {\n  return (\n    <button type=\"button\" aria-label={label} title={label} className={chromeButtonClass} {...rest}>\n      {children}\n    </button>\n  );\n}\n\nfunction NavButton({ side, label, ...rest }: React.ComponentProps<\"button\"> & { side: \"left\" | \"right\"; label: string }) {\n  return (\n    <button\n      type=\"button\"\n      aria-label={label}\n      title={label}\n      className={cn(\n        \"group/nav absolute bottom-5 grid size-9 place-items-center rounded-full border border-line-2 bg-raised/80 text-fg-2 shadow-[var(--shadow)] backdrop-blur-sm sm:bottom-auto sm:top-1/2 sm:-translate-y-1/2\",\n        side === \"left\" ? \"left-3 sm:left-4\" : \"right-3 sm:right-4\",\n        \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n        \"transition-[background-color,color,scale,opacity] duration-150 enabled:hover:bg-hover enabled:hover:text-fg enabled:active:scale-[0.92] enabled:active:duration-75\",\n        \"disabled:opacity-0\",\n        \"before:absolute before:-inset-1 before:content-['']\",\n      )}\n      {...rest}\n    >\n      {side === \"left\" ? (\n        <ChevronLeft className=\"transition-transform duration-150 ease-out group-enabled/nav:group-hover/nav:-translate-x-px\" />\n      ) : (\n        <ChevronRight className=\"transition-transform duration-150 ease-out group-enabled/nav:group-hover/nav:translate-x-px\" />\n      )}\n    </button>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/lightbox"
}
