{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"image-lightbox","type":"registry:ui","title":"Image Lightbox","description":"A thumbnail that flies out to a larger view in the middle of the screen, over a lightly blurred page.","dependencies":["@radix-ui/react-dialog@^1.1.23","@untitledui/icons@^0.0.23","motion@^14.0.0"],"files":[{"path":"components/ui/image-lightbox.tsx","type":"registry:ui","content":"\"use client\";\n\nimport * as DialogPrimitive from \"@radix-ui/react-dialog\";\nimport { AnimatePresence, motion, usePresenceData, useReducedMotion, type Transition } from \"motion/react\";\nimport Image from \"next/image\";\nimport * as React from \"react\";\nimport { CloseButton } from \"@/components/ui/close-button\";\nimport { chewyEase, overlayMotion, useAnimatedOpen } from \"@/lib/motion\";\nimport { cn } from \"@/lib/utils\";\n\nconst OPEN_RADIUS = 16;\n\ntype Box = { left: number; top: number; width: number; height: number; borderRadius: number };\ntype Preview = { src: string; aspect: number };\n\nfunction measure(element: HTMLElement): Box {\n  const rect = element.getBoundingClientRect();\n  const borderRadius = Math.min(parseFloat(getComputedStyle(element).borderTopLeftRadius) || 0, rect.width / 2);\n  return { left: rect.left, top: rect.top, width: rect.width, height: rect.height, borderRadius };\n}\n\nfunction fit(aspect: number, viewport: { width: number; height: number }, round: boolean): Box {\n  const phone = viewport.width < 640;\n  const cap = round ? 480 : 1000;\n  const maxWidth = Math.min(phone ? viewport.width - 32 : viewport.width * 0.7, cap);\n  const maxHeight = Math.min(phone ? viewport.height - 160 : viewport.height * 0.7, cap);\n  const width = Math.max(Math.min(maxWidth, maxHeight * aspect), 0);\n  const height = width / aspect;\n  return {\n    left: (viewport.width - width) / 2,\n    top: (viewport.height - height) / 2,\n    width,\n    height,\n    borderRadius: round ? width / 2 : OPEN_RADIUS,\n  };\n}\n\nfunction ImageLightbox({\n  src,\n  alt,\n  unoptimized,\n  round = false,\n  label = \"View full size\",\n  className,\n  children,\n}: {\n  src: string;\n  alt: string;\n  unoptimized?: boolean;\n  round?: boolean;\n  label?: string;\n  className?: string;\n  children: React.ReactNode;\n}) {\n  const triggerRef = React.useRef<HTMLButtonElement>(null);\n  const { open, setOpen, radixOpen, onExitComplete } = useAnimatedOpen(undefined, false, undefined);\n  const [origin, setOrigin] = React.useState<Box | null>(null);\n  const [preview, setPreview] = React.useState<Preview>({ src, aspect: 1 });\n\n  const onOpenChange = (next: boolean) => {\n    const trigger = triggerRef.current;\n    if (next && trigger) {\n      const thumbnail = trigger.querySelector(\"img\");\n      setPreview({\n        src: thumbnail?.currentSrc || src,\n        aspect: thumbnail?.naturalWidth ? thumbnail.naturalWidth / thumbnail.naturalHeight : 1,\n      });\n    }\n    setOrigin(trigger?.isConnected ? measure(trigger) : null);\n    setOpen(next);\n  };\n\n  return (\n    <DialogPrimitive.Root open={radixOpen} onOpenChange={onOpenChange}>\n      <DialogPrimitive.Trigger\n        ref={triggerRef}\n        data-slot=\"image-lightbox-trigger\"\n        aria-label={`${label}: ${alt}`}\n        className={cn(\n          \"block cursor-zoom-in select-none outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-foreground data-[state=open]:opacity-0\",\n          className,\n        )}\n      >\n        {children}\n      </DialogPrimitive.Trigger>\n      <AnimatePresence custom={origin} onExitComplete={onExitComplete}>\n        {open && (\n          <DialogPrimitive.Portal forceMount>\n            <DialogPrimitive.Overlay forceMount className=\"fixed inset-0 z-50\">\n              <motion.div\n                initial={{ opacity: 0, backdropFilter: \"blur(0px)\" }}\n                animate={{ opacity: 1, backdropFilter: \"blur(6px)\", transition: { duration: 0.3, ease: chewyEase } }}\n                exit={{ opacity: 0, backdropFilter: \"blur(0px)\", transition: { duration: 0.4, ease: chewyEase } }}\n                className=\"bg-background/40 absolute inset-0\"\n              />\n            </DialogPrimitive.Overlay>\n            <LightboxView\n              src={src}\n              alt={alt}\n              unoptimized={unoptimized}\n              round={round}\n              origin={origin}\n              preview={preview}\n              onClose={() => onOpenChange(false)}\n            />\n          </DialogPrimitive.Portal>\n        )}\n      </AnimatePresence>\n    </DialogPrimitive.Root>\n  );\n}\n\nfunction LightboxView({\n  src,\n  alt,\n  unoptimized,\n  round = false,\n  origin,\n  preview,\n  onClose,\n}: {\n  src: string;\n  alt: string;\n  unoptimized?: boolean;\n  round?: boolean;\n  origin: Box | null;\n  preview: Preview;\n  onClose: () => void;\n}) {\n  const reduceMotion = useReducedMotion();\n  const exitOrigin = usePresenceData() as Box | null | undefined;\n  const imageRef = React.useRef<HTMLDivElement>(null);\n\n  const [aspect, setAspect] = React.useState(preview.aspect);\n  const [loaded, setLoaded] = React.useState(false);\n  const [viewport, setViewport] = React.useState(() => ({ width: window.innerWidth, height: window.innerHeight }));\n\n  React.useEffect(() => {\n    const onResize = () => setViewport({ width: window.innerWidth, height: window.innerHeight });\n    window.addEventListener(\"resize\", onResize);\n    return () => window.removeEventListener(\"resize\", onResize);\n  }, []);\n\n  const target = fit(aspect, viewport, round);\n  const transition: Transition = reduceMotion ? { duration: 0.2 } : { duration: 0.4, ease: chewyEase };\n  const from = origin && !reduceMotion ? origin : { ...target, opacity: 0 };\n  const to = exitOrigin && !reduceMotion ? exitOrigin : { ...target, opacity: 0 };\n\n  return (\n    <DialogPrimitive.Content\n      forceMount\n      data-slot=\"image-lightbox\"\n      aria-describedby={undefined}\n      onClick={(event) => {\n        if (!imageRef.current?.contains(event.target as Node) && !(event.target as Element).closest(\"button\"))\n          onClose();\n      }}\n      className=\"fixed inset-0 z-50 outline-none\"\n    >\n      <motion.div\n        ref={imageRef}\n        initial={{ opacity: 1, ...from }}\n        animate={{ opacity: 1, ...target, transition }}\n        exit={{ opacity: 1, ...to, transition }}\n        className=\"bg-muted shadow-float absolute overflow-hidden\"\n      >\n        <Image src={preview.src} alt=\"\" fill unoptimized draggable={false} className=\"object-cover\" />\n        <motion.div initial={false} animate={{ opacity: loaded ? 1 : 0 }} className=\"absolute inset-0\">\n          <Image\n            src={src}\n            alt={alt}\n            fill\n            sizes=\"(min-width: 640px) 1000px, 100vw\"\n            unoptimized={unoptimized}\n            draggable={false}\n            onLoad={(event) => {\n              const image = event.currentTarget;\n              if (image.naturalWidth) setAspect(image.naturalWidth / image.naturalHeight);\n              setLoaded(true);\n            }}\n            className=\"object-cover\"\n          />\n        </motion.div>\n      </motion.div>\n\n      <motion.div\n        {...overlayMotion}\n        className=\"pointer-events-none absolute inset-x-0 top-0 flex h-16 items-center justify-between gap-4 pr-3 pl-4 sm:pr-4 sm:pl-6\"\n      >\n        <DialogPrimitive.Title className=\"truncate text-sm font-medium\">{alt}</DialogPrimitive.Title>\n        <DialogPrimitive.Close asChild>\n          <CloseButton className=\"frosted shadow-float text-foreground pointer-events-auto static size-10 shrink-0 border [&_svg]:size-5\" />\n        </DialogPrimitive.Close>\n      </motion.div>\n    </DialogPrimitive.Content>\n  );\n}\n\nexport { ImageLightbox };\n"},{"path":"components/ui/close-button.tsx","type":"registry:ui","content":"\"use client\";\n\nimport { XClose } from \"@untitledui/icons\";\nimport { motion } from \"motion/react\";\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nfunction CloseButton({ className, ...props }: React.ComponentProps<typeof motion.button>) {\n  return (\n    <motion.button\n      type=\"button\"\n      data-slot=\"close-button\"\n      whileTap={{ scale: 0.9 }}\n      className={cn(\n        \"text-muted-foreground hover:bg-accent hover:text-foreground absolute top-3 right-3 flex size-8 items-center justify-center rounded-full transition-colors outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-foreground disabled:pointer-events-none [&_svg]:size-4\",\n        className,\n      )}\n      {...props}\n    >\n      <XClose />\n      <span className=\"sr-only\">Close</span>\n    </motion.button>\n  );\n}\n\nexport { CloseButton };\n"},{"path":"lib/motion.ts","type":"registry:lib","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { useControllableState } from \"@/hooks/use-controllable-state\";\nimport type { MotionProps, Transition } from \"motion/react\";\n\nexport const chewyEase = [0.32, 0.72, 0, 1] as const;\n\nexport const growTransition = { duration: 0.6, ease: chewyEase };\n\nconst springIn: Transition = { type: \"spring\", bounce: 0.2, duration: 0.3 };\nconst quickOut: Transition = { duration: 0.12, ease: \"easeIn\" };\n\nexport const popMotion = {\n  initial: { opacity: 0, scale: 0.96 },\n  animate: { opacity: 1, scale: 1, transition: springIn },\n  exit: { opacity: 0, scale: 0.96, transition: quickOut },\n};\n\nexport const tooltipMotion = {\n  initial: { opacity: 0, scale: 0.94 },\n  animate: { opacity: 1, scale: 1, transition: { ...springIn, duration: 0.2 } },\n  exit: { opacity: 0, scale: 0.94, transition: { duration: 0.08 } },\n};\n\nexport const dialogMotion = {\n  initial: { opacity: 0, scale: 0.95, y: 8 },\n  animate: { opacity: 1, scale: 1, y: 0, transition: { type: \"spring\", bounce: 0.25, duration: 0.4 } as Transition },\n  exit: { opacity: 0, scale: 0.97, y: 4, transition: { duration: 0.15, ease: \"easeIn\" } as Transition },\n};\n\nexport const overlayMotion = {\n  initial: { opacity: 0 },\n  animate: { opacity: 1, transition: { duration: 0.2 } },\n  exit: { opacity: 0, transition: { duration: 0.15 } },\n};\n\nexport type PanelSide = \"top\" | \"right\" | \"bottom\" | \"left\";\n\nexport function panelMotion(side: PanelSide): Pick<MotionProps, \"initial\" | \"animate\" | \"exit\"> {\n  const offscreen = side === \"left\" || side === \"top\" ? \"calc(-100% - 1rem)\" : \"calc(100% + 1rem)\";\n  const horizontal = side === \"left\" || side === \"right\";\n  const hidden = horizontal ? { x: offscreen } : { y: offscreen };\n  const shown = horizontal ? { x: 0 } : { y: 0 };\n\n  return {\n    initial: hidden,\n    animate: { ...shown, transition: { duration: 0.45, ease: chewyEase } },\n    exit: { ...hidden, transition: { duration: 0.3, ease: chewyEase } },\n  };\n}\n\nexport function useAnimatedOpen(\n  openProp: boolean | undefined,\n  defaultOpen: boolean | undefined,\n  onOpenChange: ((open: boolean) => void) | undefined,\n) {\n  const [open, setOpen] = useControllableState(openProp, defaultOpen ?? false, onOpenChange);\n  const [exiting, setExiting] = React.useState(false);\n  const [wasOpen, setWasOpen] = React.useState(open);\n\n  if (open !== wasOpen) {\n    setWasOpen(open);\n    setExiting(!open);\n  }\n\n  const onExitComplete = React.useCallback(() => setExiting(false), []);\n\n  return { open, setOpen, radixOpen: open || exiting, onExitComplete };\n}\n\ntype AnimatedOpenState = { open: boolean; onExitComplete: () => void };\n\ntype OpenProps = { open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void };\n\nexport function createAnimatedRoot<P extends OpenProps>(Primitive: React.ComponentType<P>, slot: string) {\n  const Context = React.createContext<AnimatedOpenState>({ open: false, onExitComplete: () => {} });\n\n  function AnimatedRoot({ open: openProp, defaultOpen, onOpenChange, ...props }: P) {\n    const { open, setOpen, radixOpen, onExitComplete } = useAnimatedOpen(openProp, defaultOpen, onOpenChange);\n    const value = React.useMemo(() => ({ open, onExitComplete }), [open, onExitComplete]);\n    const primitiveProps = { \"data-slot\": slot, ...props, open: radixOpen, onOpenChange: setOpen } as unknown as P;\n\n    return React.createElement(Context.Provider, { value }, React.createElement(Primitive, primitiveProps));\n  }\n\n  return [AnimatedRoot, () => React.useContext(Context)] as const;\n}\n"},{"path":"hooks/use-controllable-state.ts","type":"registry:hook","content":"import * as React from \"react\";\n\nexport function useControllableState<T>(prop: T | undefined, defaultProp: T, onChange?: (value: T) => void) {\n  const [uncontrolled, setUncontrolled] = React.useState(defaultProp);\n  const isControlled = prop !== undefined;\n  const value = isControlled ? prop : uncontrolled;\n\n  const setValue = React.useCallback(\n    (next: T) => {\n      if (!isControlled) setUncontrolled(next);\n      onChange?.(next);\n    },\n    [isControlled, onChange],\n  );\n\n  return [value, setValue] as const;\n}\n"}],"cssVars":{"theme":{"shadow-float":"0 16px 36px -12px oklch(0 0 0 / 0.2), 0 2px 6px -2px oklch(0 0 0 / 0.08)"}},"css":{"@utility frosted":{"background-color":"color-mix(in oklab, var(--popover) 65%, transparent)","backdrop-filter":"blur(20px) saturate(1.6)"}}}