{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "lightbox",
  "title": "Lightbox",
  "description": "One image at a time, full bleed, with the rest of the set a key away.",
  "dependencies": [
    "@base-ui/react@^1.7.0",
    "lucide-react@^1.31.0"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/default/lightbox/lightbox.tsx",
      "content": "\"use client\"\n\n/* eslint-disable @next/next/no-img-element -- This must work outside Next.js. */\n\nimport * as React from \"react\"\nimport { Dialog } from \"@base-ui/react/dialog\"\nimport { ChevronLeft, ChevronRight, Download, X } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/** Structurally the same shape Image Grid uses, so the two interchange. */\nexport interface GalleryImage {\n  id: string\n  src: string\n  alt: string\n  width?: number\n  height?: number\n  caption?: React.ReactNode\n  description?: React.ReactNode\n  downloadUrl?: string\n  loading?: \"eager\" | \"lazy\"\n}\n\nexport interface LightboxProps {\n  images: readonly GalleryImage[]\n  /** The image being shown. Null closes it. */\n  openId: string | null\n  onOpenIdChange: (id: string | null) => void\n  renderImage?: (image: GalleryImage) => React.ReactNode\n  /** Focus lands here on close, usually the tile that opened it. */\n  finalFocus?: React.RefObject<HTMLElement | null>\n  closeLabel?: string\n  className?: string\n}\n\nconst ICON_BUTTON =\n  \"focus-visible:ring-ring inline-flex size-11 items-center justify-center rounded-full border border-white/15 bg-black/55 text-white shadow-sm backdrop-blur-sm transition-colors duration-150 hover:bg-black/75 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-black focus-visible:outline-none\"\n\n/**\n * One image at a time, full bleed, with the rest of the set a key away. Base\n * UI supplies the dialog, so the focus trap, the scroll lock, Escape, and\n * focus restoration are not ours to get wrong.\n */\nexport function Lightbox({\n  images,\n  openId,\n  onOpenIdChange,\n  renderImage,\n  finalFocus,\n  closeLabel = \"Close\",\n  className,\n}: LightboxProps) {\n  const index = images.findIndex((image) => image.id === openId)\n  const image = index >= 0 ? images[index] : null\n\n  function move(direction: -1 | 1) {\n    if (images.length < 2 || index < 0) return\n\n    const next = images[(index + direction + images.length) % images.length]\n    if (next) onOpenIdChange(next.id)\n  }\n\n  return (\n    <Dialog.Root\n      open={image !== null}\n      onOpenChange={(open) => {\n        if (!open) onOpenIdChange(null)\n      }}\n    >\n      <Dialog.Portal>\n        <Dialog.Backdrop\n          data-slot=\"lightbox-backdrop\"\n          className=\"bg-foreground/85 fixed inset-0 z-[100] transition-opacity duration-200 data-[closed]:pointer-events-none data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none\"\n        />\n        <Dialog.Viewport\n          data-slot=\"lightbox-viewport\"\n          className=\"fixed inset-0 z-[101] flex items-center justify-center p-3 data-[closed]:pointer-events-none md:p-6\"\n        >\n          {image && (\n            <Dialog.Popup\n              data-slot=\"lightbox\"\n              finalFocus={finalFocus}\n              className={cn(\n                \"bg-foreground text-background relative flex h-full max-h-[calc(100svh-1.5rem)] w-full max-w-6xl items-center justify-center overflow-hidden rounded-[var(--radius)] border border-white/10 shadow-2xl transition-[opacity,transform] duration-200 data-[ending-style]:scale-[0.98] data-[ending-style]:opacity-0 data-[starting-style]:scale-[0.98] data-[starting-style]:opacity-0 motion-reduce:transition-none md:max-h-[calc(100svh-3rem)]\",\n                className\n              )}\n              onKeyDown={(event) => {\n                if (event.key === \"ArrowLeft\") {\n                  event.preventDefault()\n                  move(-1)\n                }\n                if (event.key === \"ArrowRight\") {\n                  event.preventDefault()\n                  move(1)\n                }\n              }}\n            >\n              <Dialog.Title className=\"sr-only\">{image.alt}</Dialog.Title>\n              <Dialog.Description className=\"sr-only\">\n                Image {index + 1} of {images.length}\n              </Dialog.Description>\n\n              <span data-slot=\"lightbox-image\" className=\"contents\">\n                {renderImage?.(image) ?? (\n                  <img\n                    src={image.src}\n                    alt={image.alt}\n                    width={image.width}\n                    height={image.height}\n                    decoding=\"async\"\n                    className=\"max-h-full max-w-full object-contain\"\n                  />\n                )}\n              </span>\n\n              <div className=\"absolute top-3 right-3 flex items-center gap-2\">\n                {image.downloadUrl && (\n                  <a\n                    href={image.downloadUrl}\n                    download\n                    aria-label={`Download ${image.alt}`}\n                    className={ICON_BUTTON}\n                  >\n                    <Download aria-hidden=\"true\" size={19} />\n                  </a>\n                )}\n                <Dialog.Close aria-label={closeLabel} className={ICON_BUTTON}>\n                  <X aria-hidden=\"true\" size={20} />\n                </Dialog.Close>\n              </div>\n\n              {images.length > 1 && (\n                <>\n                  <button\n                    type=\"button\"\n                    aria-label=\"Previous image\"\n                    className={cn(\n                      ICON_BUTTON,\n                      \"absolute top-1/2 left-3 -translate-y-1/2\"\n                    )}\n                    onClick={() => move(-1)}\n                  >\n                    <ChevronLeft aria-hidden=\"true\" size={22} />\n                  </button>\n                  <button\n                    type=\"button\"\n                    aria-label=\"Next image\"\n                    className={cn(\n                      ICON_BUTTON,\n                      \"absolute top-1/2 right-3 -translate-y-1/2\"\n                    )}\n                    onClick={() => move(1)}\n                  >\n                    <ChevronRight aria-hidden=\"true\" size={22} />\n                  </button>\n                </>\n              )}\n\n              <div className=\"absolute inset-x-0 bottom-0 flex items-end justify-between gap-4 bg-gradient-to-t from-black/80 to-transparent px-4 pt-16 pb-4 text-white md:px-6 md:pb-6\">\n                <div className=\"min-w-0\">\n                  <p className=\"truncate font-semibold\">\n                    {image.caption ?? image.alt}\n                  </p>\n                  {image.description && (\n                    <div className=\"mt-1 text-sm text-white/65\">\n                      {image.description}\n                    </div>\n                  )}\n                </div>\n                <span className=\"shrink-0 text-sm font-semibold text-white/65 tabular-nums\">\n                  {index + 1} / {images.length}\n                </span>\n              </div>\n            </Dialog.Popup>\n          )}\n        </Dialog.Viewport>\n      </Dialog.Portal>\n    </Dialog.Root>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "media",
    "gallery",
    "dialog"
  ],
  "type": "registry:ui"
}