{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "lightbox",
  "type": "registry:ui",
  "title": "Lightbox",
  "description": "One image of a set at the size of the screen, on the canvas, opened from a Media. Opening is the view's expressive moment; arrow keys, Previous and Next or a swipe move through the set, and Esc, Close or a click around the image closes it.",
  "dependencies": [
    "@radix-ui/react-dialog",
    "motion"
  ],
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/button.json",
    "https://littmd.vercel.app/r/icons.json",
    "https://littmd.vercel.app/r/media.json",
    "https://littmd.vercel.app/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/lightbox.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/lightbox 2026.11.05. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Lightbox: one image of a set at the size of the screen, with the rest a key or a swipe away (components.md,\n * \"Lightbox\"). What a Media's onOpen opens: a carousel slide, a grid of details, a screenshot in a case study.\n *\n * - Radix Dialog is the accessibility layer (focus trap, Esc, focus return, the page held still behind it).\n *   Everything visual is ours.\n * - Layer: the whole screen. A canvas scrim at 97% that only fades (instant tier), so the image sits on the theme's\n *   own ground in light and dark and the page behind is a trace. No blur, no glass: the image is the surface.\n * - Image: as large as the screen allows less the bars, at its own shape, in a Media frame (the surface radius and the\n *   hairline, surface-2 while it loads, its words when it fails). Pass width and height so the frame has its shape\n *   before the image arrives; without them it takes the image's own once it loads.\n * - Bars: the label and \"3 of 12\" (caption size, tabular, --litt-fg-subtle) at the top with a round Close at the end, and\n *   the caption under the image, at the small size in --litt-fg-muted, never wider than the measure. Previous and Next are\n *   round secondary buttons beside the image on a wide screen and at the end of the caption row on a phone; 44px on a\n *   touch screen. At an end the button is disabled. Nothing loops.\n * - Motion: opening is the view's expressive moment, as a dialog's: opened from its Media frame, the image grows out\n *   of the frame (transform-origin on it, from GROW, @/motion/deal); opened any other way it rises by --litt-travel and settles from 0.98,\n *   and leaves on the exit token. `tier=\"responsive\"` when the view has spent it. Moving to the next image fades it in\n *   from --litt-travel on the side it comes from and the last one out by half that, on the responsive tier.\n * - Touch: a finger swipes the image sideways one to one (useDismissDrag); past --litt-dismiss-distance of its width, or\n *   faster than --litt-dismiss-velocity, it carries on out from where the finger let go and the next one comes in. Pulled\n *   the way there is nothing it resists. The buttons stay: a swipe is never the only way.\n * - Keys: ← and → move one image, Home and End go to the first and the last, Esc closes, Tab moves between the\n *   buttons. Focus lands on the panel, so the arrows work at once, and goes back to what opened it, or wherever\n *   returnFocus says (the slide of the image it closed on).\n * - A tap or click on the ground around the image closes it, as a dialog's scrim does.\n * - Accessibility: a dialog named by the label; the image's alt is its name and the caption is under it. Moving says\n *   \"Image 4 of 12\" once in a polite status. Under reduced motion every change is a cut.\n */\nimport { useEffect, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from \"react\";\nimport * as RadixDialog from \"@radix-ui/react-dialog\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { useDevice, useExit, useTransition, useTravel } from \"../../lib/litt/motion\";\nimport { useDismissDrag, type DismissInfo } from \"../../lib/litt/motion/drag\";\nimport { GROW, useGrowFrom, useOverlayMotion } from \"../../lib/litt/motion/deal\";\nimport { Button } from \"./button\";\nimport { ChevronLeftIcon, ChevronRightIcon, XIcon } from \"./icons\";\nimport { Media } from \"./media\";\n\n/* Radix's asChild, spread in: shadcn's CLI rewrites a written asChild to Base UI's render prop in base-* hosts. */\nconst asChildProps = { asChild: true } as const;\n\nexport type LightboxItem = {\n  /** The image at the size the lightbox shows it. */\n  src: string;\n  /** What the image shows. It names the image for screen readers. */\n  alt: string;\n  /** Other sizes of the image, for the browser to pick from. */\n  srcSet?: string;\n  /** The image's own width and height in pixels, so its frame has the right shape before it loads. */\n  width?: number;\n  height?: number;\n  /** A line under the image. */\n  caption?: ReactNode;\n};\n\nexport type LightboxProps = {\n  /** What the set is, shown at the top and naming the dialog: \"Halo site\". */\n  label: string;\n  /** The images, in order. */\n  items: LightboxItem[];\n  /** The image shown, or null when the lightbox is closed. Set it from a Media's onOpen. */\n  index: number | null;\n  /** Called with the next image when the person moves, and with null when they close it. */\n  onIndexChange: (index: number | null) => void;\n  /** The view's one expressive moment by default. Pass \"responsive\" when the view has already spent it. */\n  tier?: \"expressive\" | \"responsive\";\n  /** Where focus goes when it closes, given the image it closed on: the slide or thumbnail of that image. Default: the element that opened it. */\n  returnFocus?: (index: number) => HTMLElement | null | undefined;\n};\n\nconst SCRIM = \"absolute inset-0 bg-[color-mix(in_oklab,var(--litt-canvas)_97%,transparent)]\";\nconst ROUND = \"pointer-coarse:h-litt-control-md pointer-coarse:w-litt-control-md\";\n\n/** A set of images at the size of the screen, one at a time, opened from a Media and closed with Esc or Close. */\nexport function Lightbox({ label, items, index, onIndexChange, tier = \"expressive\", returnFocus }: LightboxProps) {\n  const open = index !== null && items.length > 0;\n  /* While it leaves, the lightbox keeps showing the image it closed on. */\n  const [shown, setShown] = useState(0);\n  const at = open ? Math.max(0, Math.min(items.length - 1, index)) : Math.min(shown, Math.max(0, items.length - 1));\n  if (open && at !== shown) setShown(at);\n\n  return (\n    <RadixDialog.Root\n      open={open}\n      onOpenChange={(next) => {\n        if (!next) onIndexChange(null);\n      }}\n    >\n      <RadixDialog.Portal forceMount>\n        <AnimatePresence>\n          {open && (\n            <Layer\n              key=\"layer\"\n              label={label}\n              items={items}\n              index={at}\n              go={(to) => onIndexChange(to)}\n              close={() => onIndexChange(null)}\n              tier={tier}\n              returnFocus={returnFocus}\n            />\n          )}\n        </AnimatePresence>\n      </RadixDialog.Portal>\n    </RadixDialog.Root>\n  );\n}\n\ntype LayerProps = {\n  label: string;\n  items: LightboxItem[];\n  index: number;\n  go: (index: number) => void;\n  close: () => void;\n  tier: \"expressive\" | \"responsive\";\n  returnFocus?: (index: number) => HTMLElement | null | undefined;\n};\n\nfunction Layer({ label, items, index, go, close, tier, returnFocus }: LayerProps) {\n  const { compact, coarse } = useDevice();\n  const enter = useTransition(tier);\n  const exit = useExit(tier);\n  const fadeIn = useTransition(\"instant\");\n  const fadeOut = useExit(\"instant\");\n  const travel = useTravel();\n  const panel = useRef<HTMLDivElement | null>(null);\n  /* Opened from its Media frame (a press), the image grows out of the frame; opened any other way, it rises. */\n  const grow = useGrowFrom<HTMLDivElement>(true);\n  /* Under Device the image switches on and off where it stands (@/motion/deal, the motion languages). */\n  const om = useOverlayMotion();\n  /* Which way the last move went: 1 forward, -1 back. The image coming in and the one going out both read it. */\n  const [dir, setDir] = useState<1 | -1>(1);\n  const [status, setStatus] = useState(\"\");\n  const total = items.length;\n  const item = items[index]!;\n  const canPrevious = index > 0;\n  const canNext = index < total - 1;\n\n  const move = (to: number) => {\n    if (to < 0 || to >= total || to === index) return;\n    setDir(to > index ? 1 : -1);\n    setStatus(`Image ${to + 1} of ${total}`);\n    go(to);\n  };\n\n  /* The images either side load while this one is looked at, so moving never waits. */\n  useEffect(() => {\n    for (const i of [index - 1, index + 1]) {\n      const near = items[i];\n      if (!near) continue;\n      const img = new Image();\n      if (near.srcSet) img.srcset = near.srcSet;\n      img.src = near.src;\n    }\n  }, [index, items]);\n\n  const onKeyDown = (e: KeyboardEvent) => {\n    if (e.altKey || e.ctrlKey || e.metaKey) return;\n    const to = e.key === \"ArrowRight\" ? index + 1 : e.key === \"ArrowLeft\" ? index - 1 : e.key === \"Home\" ? 0 : e.key === \"End\" ? total - 1 : null;\n    if (to === null) return;\n    e.preventDefault();\n    move(to);\n  };\n\n  /* The ground around the image closes it, as a scrim does. The image and the buttons do not. */\n  const onGround = (e: MouseEvent) => {\n    if (!(e.target as Element).closest(\"[data-lightbox-image], button\")) close();\n  };\n\n  const previousButton = (\n    <Button iconOnly size=\"sm\" aria-label=\"Previous image\" disabled={!canPrevious} onClick={() => move(index - 1)} className={ROUND}>\n      <ChevronLeftIcon />\n    </Button>\n  );\n  const nextButton = (\n    <Button iconOnly size=\"sm\" aria-label=\"Next image\" disabled={!canNext} onClick={() => move(index + 1)} className={ROUND}>\n      <ChevronRightIcon />\n    </Button>\n  );\n\n  return (\n    <div className=\"fixed inset-0 z-50\">\n      <RadixDialog.Overlay forceMount {...asChildProps}>\n        <motion.div\n          className={SCRIM}\n          initial={{ opacity: 0 }}\n          animate={{ opacity: 1, transition: fadeIn }}\n          exit={{ opacity: 0, transition: fadeOut }}\n        />\n      </RadixDialog.Overlay>\n\n      <RadixDialog.Content\n        forceMount\n        {...asChildProps}\n        ref={panel}\n        aria-describedby={undefined}\n        onOpenAutoFocus={(e) => {\n          e.preventDefault();\n          panel.current?.focus({ preventScroll: true });\n        }}\n        onCloseAutoFocus={(e) => {\n          const el = returnFocus?.(index);\n          if (!el) return;\n          e.preventDefault();\n          el.focus();\n        }}\n        onKeyDown={onKeyDown}\n      >\n        <motion.div\n          tabIndex={-1}\n          className=\"absolute inset-0 grid grid-rows-[auto_minmax(0,1fr)_auto] gap-4 pt-[max(1rem,env(safe-area-inset-top,0px))] pb-[max(1rem,env(safe-area-inset-bottom,0px))] outline-none sm:gap-6 sm:py-6\"\n          initial={{ opacity: 0 }}\n          animate={{ opacity: 1, transition: fadeIn }}\n          exit={{ opacity: 0, transition: exit }}\n        >\n          <div className=\"flex min-w-0 items-center justify-between gap-4 px-4 sm:px-6\">\n            <div className=\"flex min-w-0 items-baseline gap-3\">\n              <RadixDialog.Title className=\"truncate text-litt-small font-litt-title text-litt-fg\">{label}</RadixDialog.Title>\n              <p aria-hidden=\"true\" className=\"shrink-0 text-litt-caption font-normal tabular-nums text-litt-fg-subtle\">\n                {index + 1} of {total}\n              </p>\n            </div>\n            <RadixDialog.Close {...asChildProps}>\n              <Button iconOnly size=\"sm\" aria-label=\"Close\" className={ROUND}>\n                <XIcon />\n              </Button>\n            </RadixDialog.Close>\n          </div>\n\n          <div\n            onClick={onGround}\n            className={\"grid min-h-0 items-center gap-4 px-4 sm:px-6 \" + (compact ? \"grid-cols-[minmax(0,1fr)]\" : \"grid-cols-[auto_minmax(0,1fr)_auto]\")}\n          >\n            {!compact && previousButton}\n            <motion.div\n              ref={grow.ref}\n              className=\"grid size-full min-h-0 min-w-0\"\n              initial={om.switches ? { opacity: 0 } : grow.grows ? { opacity: 0, scale: GROW } : { opacity: 0, y: travel, scale: 0.98 }}\n              animate={{ opacity: 1, y: 0, scale: 1, transition: om.switches ? om.switchIn : enter }}\n              exit={\n                om.switches\n                  ? { opacity: 0, transition: om.switchOut }\n                  : grow.grows\n                    ? { opacity: 0, scale: GROW, transition: exit }\n                    : { opacity: 0, y: travel / 2, scale: 0.98, transition: exit }\n              }\n            >\n              <AnimatePresence initial={false} custom={dir}>\n                <Slide key={index} item={item} dir={dir} swipe={coarse} canPrevious={canPrevious} canNext={canNext} onSwipe={(by) => move(index + by)} />\n              </AnimatePresence>\n            </motion.div>\n            {!compact && nextButton}\n          </div>\n\n          <div className=\"flex min-h-litt-control-sm items-start justify-between gap-4 px-4 sm:px-6\">\n            <p className=\"max-w-(--litt-measure) min-w-0 text-litt-small text-litt-fg-muted\">{item.caption}</p>\n            {compact && (\n              <div className=\"flex shrink-0 items-center gap-2\">\n                {previousButton}\n                {nextButton}\n              </div>\n            )}\n          </div>\n\n          <p role=\"status\" className=\"sr-only\">\n            {status}\n          </p>\n        </motion.div>\n      </RadixDialog.Content>\n    </div>\n  );\n}\n\ntype SlideProps = {\n  item: LightboxItem;\n  dir: 1 | -1;\n  swipe: boolean;\n  canPrevious: boolean;\n  canNext: boolean;\n  onSwipe: (by: 1 | -1) => void;\n};\n\n/** One image, sized to the stage at its own shape. A finger swipes it away to the next one. */\nfunction Slide({ item, dir, swipe, canPrevious, canNext, onSwipe }: SlideProps) {\n  const travel = useTravel();\n  const change = useTransition(\"responsive\");\n  const leave = useExit(\"responsive\");\n  const node = useRef<HTMLDivElement>(null);\n  /* The way a swipe sent this image. A button or a key leaves it null. */\n  const swiped = useRef<\"left\" | \"right\" | null>(null);\n  /* The image's shape: its width and height when given, else its own once it loads, else 3:2 while it loads. */\n  const [natural, setNatural] = useState<number | null>(null);\n  const ratio = item.width && item.height ? item.width / item.height : (natural ?? 3 / 2);\n\n  /* A swipe left goes to the next image and a swipe right to the one before; with only one of them, only that way. */\n  const drag = useDismissDrag({\n    direction: canPrevious && canNext ? \"sideways\" : canNext ? \"left\" : \"right\",\n    enabled: swipe && (canPrevious || canNext),\n    tier: \"responsive\",\n    onDismiss: (info: DismissInfo) => {\n      if (info.direction === \"down\") return;\n      swiped.current = info.direction;\n      onSwipe(info.direction === \"left\" ? 1 : -1);\n    },\n  });\n\n  return (\n    <motion.div\n      ref={node}\n      {...drag.panel}\n      onPointerDown={drag.onPointerDown}\n      custom={dir}\n      className=\"grid size-full min-h-0 min-w-0 place-items-center [grid-area:1/1] [container-type:size]\"\n      variants={{\n        from: (d: 1 | -1) => ({ opacity: 0, x: d * travel }),\n        here: { opacity: 1, x: 0, transition: change },\n        /* Read when it leaves: a swipe carries on one width more from where the finger let go; a button or a key\n           fades it out by half the travel, away from the side the next one comes from. */\n        gone: (d: 1 | -1) => {\n          if (swiped.current) {\n            const now = drag.offset.get() as unknown;\n            const from = typeof now === \"number\" ? now : 0;\n            const by = node.current?.offsetWidth ?? 0;\n            return { opacity: 0, x: from + (swiped.current === \"left\" ? -by : by), transition: drag.exit };\n          }\n          return { opacity: 0, x: (-d * travel) / 2, transition: leave };\n        },\n      }}\n      initial=\"from\"\n      animate=\"here\"\n      exit=\"gone\"\n    >\n      <div data-lightbox-image=\"\" style={{ width: `min(100cqw, calc(100cqh * ${ratio}))` }}>\n        <Media\n          src={item.src}\n          srcSet={item.srcSet}\n          sizes=\"100vw\"\n          alt={item.alt}\n          ratio={ratio}\n          priority\n          onLoad={(img) => {\n            if (img.naturalWidth && img.naturalHeight) setNatural(img.naturalWidth / img.naturalHeight);\n          }}\n        />\n      </div>\n    </motion.div>\n  );\n}\n",
      "target": "components/litt/lightbox.tsx"
    }
  ],
  "docs": "Import Lightbox from components/litt/lightbox and hold the open index in state: set it from each Media's onOpen and pass setIndex as onIndexChange. Give each item its width and height.",
  "meta": {
    "version": "2026.11.05",
    "released": "2026-11-05",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
