{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "image-lightbox",
  "type": "registry:ui",
  "title": "Gallery lightbox",
  "description": "Expands a thumbnail into an immersive gallery while managing focus and keyboard browsing.",
  "dependencies": [
    "motion"
  ],
  "categories": [
    "cards-media"
  ],
  "docs": "https://motion-lexicon.pages.dev/en/components/image-lightbox/",
  "meta": {
    "engines": [
      "motion"
    ],
    "runtimeCost": "medium",
    "signature": "Shared-element transition from thumbnail to gallery",
    "sceneFamily": "editorial-warm",
    "motionRole": "gentle",
    "primaryState": "Immersive gallery with thumbnail context",
    "assetProvenance": "self-contained"
  },
  "files": [
    {
      "path": "src/registry/components/image-lightbox.tsx",
      "type": "registry:ui",
      "target": "components/motion-lexicon/image-lightbox.tsx",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n  type ReactNode,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { AnimatePresence, LayoutGroup, motion, useReducedMotion } from \"motion/react\";\n\nexport type ImageLightboxItem = {\n  id: string;\n  title: string;\n  caption?: string;\n  meta?: string;\n  art: ReactNode;\n};\n\nexport type ImageLightboxProps = {\n  items: readonly ImageLightboxItem[];\n  label?: string;\n  copy?: Partial<ImageLightboxCopy>;\n  className?: string;\n  onChange?: (item: ImageLightboxItem, index: number) => void;\n};\n\nexport type ImageLightboxCopy = {\n  gallery: string;\n  works: (count: number) => string;\n  empty: string;\n  open: (title: string) => string;\n  close: string;\n  previous: string;\n  next: string;\n};\n\nconst DEFAULT_COPY: ImageLightboxCopy = {\n  gallery: \"Gallery\",\n  works: (count) => `${String(count).padStart(2, \"0\")} works`,\n  empty: \"No works in this gallery\",\n  open: (title) => `Open ${title}`,\n  close: \"Close gallery\",\n  previous: \"Previous image\",\n  next: \"Next image\",\n};\n\ntype NavigationSource = \"keyboard\" | \"pointer\";\n\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\nconst EASE_MOVE = [0.77, 0, 0.175, 1] as const;\n\nconst FOCUSABLE = [\n  \"a[href]\",\n  \"button:not([disabled])\",\n  \"input:not([disabled])\",\n  \"select:not([disabled])\",\n  \"textarea:not([disabled])\",\n  \"[tabindex]:not([tabindex='-1'])\",\n].join(\",\");\n\nconst CLOSE_ICON = (\n  <svg viewBox=\"0 0 20 20\" fill=\"none\" className=\"size-4\" aria-hidden=\"true\">\n    <path d=\"m5.5 5.5 9 9m0-9-9 9\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" />\n  </svg>\n);\n\nconst ARROW_ICON = (\n  <svg viewBox=\"0 0 20 20\" fill=\"none\" className=\"size-4\" aria-hidden=\"true\">\n    <path d=\"m8.1 5.1 4.7 4.9-4.7 4.9\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n  </svg>\n);\n\nfunction focusableWithin(root: HTMLElement) {\n  return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(\n    (element) => element.getClientRects().length > 0,\n  );\n}\n\nexport function ImageLightbox({\n  items,\n  label = \"Image collection\",\n  copy: copyOverrides,\n  className = \"\",\n  onChange,\n}: ImageLightboxProps) {\n  const copy = { ...DEFAULT_COPY, ...copyOverrides };\n  const reduced = useReducedMotion() === true;\n  const layoutGroupId = useId();\n  const titleId = useId();\n  const dialogRef = useRef<HTMLDivElement>(null);\n  const closeRef = useRef<HTMLButtonElement>(null);\n  const returnFocusRef = useRef<HTMLElement | null>(null);\n  const triggerRefs = useRef(new Map<string, HTMLButtonElement>());\n  const focusFrameRef = useRef<number | null>(null);\n  const backdropDownRef = useRef(false);\n  const navigationSourceRef = useRef<NavigationSource>(\"pointer\");\n  const [portalNode, setPortalNode] = useState<HTMLDivElement | null>(null);\n  const [activeId, setActiveId] = useState<string | null>(null);\n  const activeIndex = activeId === null\n    ? -1\n    : items.findIndex((item) => item.id === activeId);\n  const activeItem = activeIndex < 0 ? null : items[activeIndex];\n  const open = activeItem !== null;\n\n  useEffect(() => {\n    if (activeId !== null && activeIndex < 0) setActiveId(null);\n  }, [activeId, activeIndex]);\n\n  useEffect(() => {\n    const node = document.createElement(\"div\");\n    node.dataset.imageLightboxPortal = \"\";\n    document.body.appendChild(node);\n    setPortalNode(node);\n    return () => {\n      node.remove();\n    };\n  }, []);\n\n  useEffect(() => {\n    if (!open || !portalNode) return;\n    const triggers = triggerRefs.current;\n    const body = document.body;\n    const previousOverflow = body.style.overflow;\n    const siblings = Array.from(body.children).filter((child) => child !== portalNode);\n    const inertState = siblings.map((element) => [element, element.getAttribute(\"inert\")] as const);\n    siblings.forEach((element) => element.setAttribute(\"inert\", \"\"));\n    body.style.overflow = \"hidden\";\n\n    focusFrameRef.current = requestAnimationFrame(() => {\n      focusFrameRef.current = null;\n      closeRef.current?.focus({ preventScroll: true });\n    });\n\n    return () => {\n      if (focusFrameRef.current !== null) {\n        cancelAnimationFrame(focusFrameRef.current);\n        focusFrameRef.current = null;\n      }\n      body.style.overflow = previousOverflow;\n      inertState.forEach(([element, previous]) => {\n        if (previous === null) element.removeAttribute(\"inert\");\n        else element.setAttribute(\"inert\", previous);\n      });\n      const previousTarget = returnFocusRef.current;\n      const returnTarget = previousTarget?.isConnected\n        ? previousTarget\n        : Array.from(triggers.values()).find((trigger) => trigger.isConnected);\n      if (returnTarget?.isConnected) returnTarget.focus({ preventScroll: true });\n    };\n  }, [open, portalNode]);\n\n  const openImage = (\n    index: number,\n    trigger: HTMLElement,\n    source: NavigationSource,\n  ) => {\n    const item = items[index];\n    if (!item) return;\n    navigationSourceRef.current = source;\n    returnFocusRef.current = trigger;\n    setActiveId(item.id);\n    onChange?.(item, index);\n  };\n\n  const close = useCallback((source: NavigationSource = \"pointer\") => {\n    navigationSourceRef.current = source;\n    setActiveId(null);\n  }, []);\n\n  const move = useCallback(\n    (delta: -1 | 1, source: NavigationSource) => {\n      if (items.length < 2 || activeIndex < 0) return;\n      navigationSourceRef.current = source;\n      const next = (activeIndex + delta + items.length) % items.length;\n      setActiveId(items[next].id);\n      onChange?.(items[next], next);\n    },\n    [activeIndex, items, onChange],\n  );\n\n  useEffect(() => {\n    if (!open) return;\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") {\n        event.preventDefault();\n        close(\"keyboard\");\n        return;\n      }\n      if (event.key === \"ArrowLeft\" || event.key === \"ArrowRight\") {\n        event.preventDefault();\n        move(event.key === \"ArrowLeft\" ? -1 : 1, \"keyboard\");\n        return;\n      }\n      if (event.key !== \"Tab\") return;\n      const dialog = dialogRef.current;\n      if (!dialog) return;\n      const focusable = focusableWithin(dialog);\n      if (focusable.length === 0) {\n        event.preventDefault();\n        dialog.focus({ preventScroll: true });\n        return;\n      }\n      const first = focusable[0];\n      const last = focusable[focusable.length - 1];\n      if (event.shiftKey && (document.activeElement === first || document.activeElement === dialog)) {\n        event.preventDefault();\n        last.focus({ preventScroll: true });\n      } else if (!event.shiftKey && document.activeElement === last) {\n        event.preventDefault();\n        first.focus({ preventScroll: true });\n      }\n    };\n    document.addEventListener(\"keydown\", onKeyDown);\n    return () => document.removeEventListener(\"keydown\", onKeyDown);\n  }, [close, move, open]);\n\n  const instant = reduced || navigationSourceRef.current === \"keyboard\";\n  const sharedTransition = instant\n    ? { duration: 0 }\n    : { duration: 0.26, ease: EASE_MOVE };\n  const controlPressClass = reduced\n    ? \"\"\n    : \"transition-[background-color,transform] duration-150 active:scale-[0.96]\";\n\n  return (\n    <LayoutGroup id={layoutGroupId}>\n      <section aria-labelledby={titleId} className={`w-full rounded-[18px] border border-[#3a3025]/15 bg-[#eee1cb] p-3 shadow-[0_18px_45px_-34px_rgba(51,37,21,.7)] dark:border-white/10 dark:bg-[#1c1814] ${className}`}>\n        <header className=\"mb-3 flex min-h-11 items-end justify-between gap-4 px-1\">\n          <div className=\"min-w-0\">\n            <span className=\"block font-mono text-[9px] tracking-[.16em] text-[#746452] dark:text-[#e6cda8]/60\">\n              {copy.gallery}\n            </span>\n            <h3 id={titleId} className=\"mt-0.5 font-serif text-[18px] leading-none tracking-[-0.04em] text-[#29231c] dark:text-[#fff1dc]\">\n              {label}\n            </h3>\n          </div>\n          <span className=\"shrink-0 font-mono text-[9px] tracking-[.1em] text-[#746452] dark:text-[#e6cda8]/60\">\n            {copy.works(items.length)}\n          </span>\n        </header>\n        {items.length === 0 ? (\n          <div role=\"status\" className=\"grid min-h-28 place-items-center rounded-xl border border-[#3a3025]/15 bg-[#fff6e8] px-4 text-center text-[12px] text-[#6e5d49] dark:border-white/[0.12] dark:bg-[#181818] dark:text-neutral-300\">\n            {copy.empty}\n          </div>\n        ) : <div className=\"grid grid-cols-2 gap-2 sm:grid-cols-3\">\n          {items.map((item, index) => (\n            <button\n              key={item.id}\n              ref={(node) => {\n                if (node) triggerRefs.current.set(item.id, node);\n                else triggerRefs.current.delete(item.id);\n              }}\n              type=\"button\"\n              aria-label={copy.open(item.title)}\n              onClick={(event) =>\n                openImage(\n                  index,\n                  event.currentTarget,\n                  event.detail === 0 ? \"keyboard\" : \"pointer\",\n                )\n              }\n              className=\"group overflow-hidden rounded-xl border border-[#3a3025]/15 bg-[#fff6e8] text-left outline-none transition-[border-color,box-shadow,transform] duration-200 focus-visible:ring-2 focus-visible:ring-[#4568FF] focus-visible:ring-offset-2 focus-visible:ring-offset-[#eee1cb] [@media(hover:hover)_and_(pointer:fine)]:hover:-translate-y-0.5 [@media(hover:hover)_and_(pointer:fine)]:hover:shadow-[0_16px_24px_-20px_rgba(53,36,17,.75)] dark:border-white/[0.12] dark:bg-[#181818]\"\n            >\n              <motion.span\n                layoutId={`${layoutGroupId}-${item.id}`}\n                transition={sharedTransition}\n                className=\"block aspect-[4/3] overflow-hidden bg-[#d3c2ab] dark:bg-white/[0.04]\"\n              >\n                {item.art}\n              </motion.span>\n              <span className=\"flex min-h-[52px] items-center justify-between gap-2 border-t border-[#3a3025]/10 px-2.5 dark:border-white/[0.1]\">\n                <span className=\"truncate text-[11px] font-medium text-[#29231c] dark:text-neutral-100\">\n                  {item.title}\n                </span>\n                <span className=\"font-mono text-[8.5px] tracking-[.1em] text-[#7a6750] dark:text-neutral-400\">\n                  {item.meta ?? String(index + 1).padStart(2, \"0\")}\n                </span>\n              </span>\n            </button>\n          ))}\n        </div>}\n      </section>\n\n      {portalNode\n        ? createPortal(\n            <AnimatePresence>\n              {activeItem && activeIndex >= 0 ? (\n                <motion.div\n                  key=\"image-lightbox\"\n                  initial={{ opacity: 0 }}\n                  animate={{ opacity: 1 }}\n                  exit={{ opacity: 0 }}\n                  transition={{ duration: instant ? 0 : 0.18, ease: EASE_OUT }}\n                  onPointerDown={(event) => {\n                    backdropDownRef.current = event.target === event.currentTarget;\n                  }}\n                  onClick={(event) => {\n                    if (event.target === event.currentTarget && backdropDownRef.current) {\n                      close(\"pointer\");\n                    }\n                    backdropDownRef.current = false;\n                  }}\n                  className=\"fixed inset-0 z-50 grid place-items-center bg-black/80 p-3 sm:p-6\"\n                >\n                  <motion.div\n                    ref={dialogRef}\n                    role=\"dialog\"\n                    aria-modal=\"true\"\n                    aria-labelledby={`${layoutGroupId}-dialog-title`}\n                    aria-describedby={activeItem.caption ? `${layoutGroupId}-dialog-caption` : undefined}\n                    tabIndex={-1}\n                    initial={instant ? false : { opacity: 0, transform: \"translateY(10px) scale(0.98)\" }}\n                    animate={{ opacity: 1, transform: \"translateY(0px) scale(1)\" }}\n                    exit={instant ? { opacity: 0 } : { opacity: 0, transform: \"translateY(6px) scale(0.985)\" }}\n                    transition={{ duration: instant ? 0 : 0.24, ease: EASE_OUT }}\n                    className=\"grid max-h-[calc(100dvh-24px)] w-full max-w-[980px] grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden rounded-[18px] border border-[#f3d7ad]/20 bg-[#17130f] outline-none sm:max-h-[calc(100dvh-48px)]\"\n                  >\n                    <header className=\"flex min-h-14 items-center justify-between gap-4 border-b border-white/10 px-3\">\n                      <div className=\"min-w-0\">\n                        <span className=\"block font-mono text-[8.5px] text-neutral-500 dark:text-neutral-400\">\n                          {String(activeIndex + 1).padStart(2, \"0\")} / {String(items.length).padStart(2, \"0\")}\n                        </span>\n                        <h3\n                          id={`${layoutGroupId}-dialog-title`}\n                          className=\"truncate font-serif text-[18px] leading-none tracking-[-0.04em] text-[#fff0db]\"\n                        >\n                          {activeItem.title}\n                        </h3>\n                      </div>\n                      <button\n                        ref={closeRef}\n                        type=\"button\"\n                        aria-label={copy.close}\n                        onClick={() => close(\"pointer\")}\n                        className={`grid size-11 shrink-0 place-items-center rounded-full border border-white/15 bg-[#fff0db] text-[#292016] outline-none focus-visible:ring-2 focus-visible:ring-[#8eb9ff] focus-visible:ring-offset-2 focus-visible:ring-offset-[#17130f] ${controlPressClass}`}\n                      >\n                        {CLOSE_ICON}\n                      </button>\n                    </header>\n\n                    <div className=\"relative grid min-h-0 place-items-center overflow-hidden bg-[radial-gradient(circle_at_22%_18%,rgba(205,115,69,.18),transparent_32%),radial-gradient(circle_at_82%_78%,rgba(77,117,108,.2),transparent_40%)] p-3 sm:p-5\">\n                      <AnimatePresence initial={false} mode=\"popLayout\">\n                        <motion.div\n                          key={activeItem.id}\n                          layoutId={`${layoutGroupId}-${activeItem.id}`}\n                          role=\"img\"\n                          aria-label={activeItem.title}\n                          initial={instant ? false : { opacity: 0.65, filter: \"blur(2px)\", transform: \"scale(0.985)\" }}\n                          animate={{ opacity: 1, filter: \"blur(0px)\", transform: \"scale(1)\" }}\n                          exit={instant ? { opacity: 0 } : { opacity: 0, filter: \"blur(2px)\", transform: \"scale(0.99)\" }}\n                          transition={sharedTransition}\n                          className=\"aspect-[4/3] max-h-full w-full max-w-[760px] overflow-hidden rounded-xl border border-[#f6dcc0]/20 bg-[#3a2c22] shadow-[0_28px_70px_-34px_rgba(0,0,0,.9)]\"\n                        >\n                          {activeItem.art}\n                        </motion.div>\n                      </AnimatePresence>\n                    </div>\n\n                    <footer className=\"grid min-h-[68px] grid-cols-[44px_minmax(0,1fr)_44px] items-center gap-3 border-t border-white/10 px-3\">\n                      <button\n                        type=\"button\"\n                        aria-label={copy.previous}\n                        onClick={() => move(-1, \"pointer\")}\n                        disabled={items.length < 2}\n                        className={`grid size-11 place-items-center rounded-full border border-white/15 bg-white/10 text-[#fff0db] outline-none disabled:opacity-35 focus-visible:ring-2 focus-visible:ring-[#8eb9ff] focus-visible:ring-offset-2 focus-visible:ring-offset-[#17130f] ${controlPressClass}`}\n                      >\n                        <span className=\"rotate-180\">{ARROW_ICON}</span>\n                      </button>\n                      <p\n                        id={`${layoutGroupId}-dialog-caption`}\n                        className=\"min-w-0 text-center text-[11px] leading-[1.45] text-[#ead6bb]/68 [overflow-wrap:anywhere]\"\n                      >\n                        {activeItem.caption ?? activeItem.title}\n                      </p>\n                      <button\n                        type=\"button\"\n                        aria-label={copy.next}\n                        onClick={() => move(1, \"pointer\")}\n                        disabled={items.length < 2}\n                        className={`grid size-11 place-items-center rounded-full border border-white/15 bg-white/10 text-[#fff0db] outline-none disabled:opacity-35 focus-visible:ring-2 focus-visible:ring-[#8eb9ff] focus-visible:ring-offset-2 focus-visible:ring-offset-[#17130f] ${controlPressClass}`}\n                      >\n                        {ARROW_ICON}\n                      </button>\n                    </footer>\n                  </motion.div>\n                </motion.div>\n              ) : null}\n            </AnimatePresence>,\n            portalNode,\n          )\n        : null}\n    </LayoutGroup>\n  );\n}\n"
    }
  ]
}
