{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"lightbox","type":"registry:ui","title":"Lightbox","description":"Zoom that returns where it started.","dependencies":["motion"],"categories":["gesture"],"docs":"https://www.interior.dev/docs/lightbox","files":[{"path":"registry/interior/lightbox.tsx","type":"registry:ui","target":"components/interior/lightbox.tsx","content":"\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n} from \"motion/react\";\n\nconst CELL = {\n  type: \"spring\",\n  stiffness: 520,\n  damping: 34,\n  mass: 0.45,\n} as const;\n\nconst HOME = {\n  type: \"spring\",\n  stiffness: 150,\n  damping: 27,\n  mass: 1,\n} as const;\n\nconst VEIL = {\n  type: \"spring\",\n  stiffness: 260,\n  damping: 34,\n  mass: 0.8,\n} as const;\n\nconst EASE = [0.23, 1, 0.32, 1] as const;\nconst EXIT = { duration: 0.2, ease: [0.4, 0, 1, 1] } as const;\n\nconst GLYPH = {\n  type: \"spring\",\n  stiffness: 700,\n  damping: 46,\n  mass: 0.5,\n} as const;\n\nconst TOGGLE = 2.5;\nconst KEY_ZOOM = 1.6;\nconst KEY_PAN = 56;\nconst WHEEL_RATE = 140;\nconst SLOP = 8;\nconst NEAR_HOME = 1.02;\nconst SNAP_HOME = 1.05;\n\nconst CHROME_BUTTON =\n  \"grid size-8 place-items-center rounded-[9px] border border-stone-200 bg-white text-stone-500 outline-none transition-[border-color,color,box-shadow] duration-150 hover:border-stone-300 hover:text-stone-700 focus-visible:border-[#4568FF] focus-visible:shadow-[0_1px_2px_rgba(28,25,23,0.08),0_10px_20px_-14px_rgba(69,104,255,0.6)] dark:border-white/[0.16] dark:bg-[#1D1D1A] dark:text-stone-400 dark:hover:border-white/20 dark:hover:text-stone-200 dark:focus-visible:border-[#93B0FF] dark:focus-visible:shadow-[0_10px_20px_-14px_rgba(147,176,255,0.5)]\";\n\ntype Spring = {\n  type: \"spring\";\n  stiffness: number;\n  damping: number;\n  mass: number;\n};\n\nconst clamp = (v: number, lo: number, hi: number) =>\n  Math.min(hi, Math.max(lo, v));\n\ntype Drag = {\n  id: number;\n  from: { x: number; y: number };\n  x: number;\n  y: number;\n};\n\nexport type UseLightboxOptions = {\n  maxScale?: number;\n  steps?: number;\n  disabled?: boolean;\n  onDismiss?: () => void;\n};\n\nexport function useLightbox<\n  Frame extends HTMLElement = HTMLDivElement,\n  Content extends HTMLElement = HTMLImageElement,\n>({\n  maxScale = 4,\n  steps = 8,\n  disabled = false,\n  onDismiss,\n}: UseLightboxOptions = {}) {\n  const cells = Math.max(1, Math.round(steps));\n  const top = Math.max(1.1, maxScale);\n\n  const frameRef = useRef<Frame>(null);\n  const contentRef = useRef<Content>(null);\n\n  const scale = useMotionValue(1);\n  const x = useMotionValue(0);\n  const y = useMotionValue(0);\n\n  const [step, setStep] = useState(0);\n  const [settled, setSettled] = useState(0);\n\n  const stepRef = useRef(0);\n  const settledRef = useRef(0);\n  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const drag = useRef<Drag | null>(null);\n  const onContent = useRef(false);\n\n  const reduced = useReducedMotion();\n  const dismiss = useRef(onDismiss);\n  dismiss.current = onDismiss;\n\n  const toStep = useCallback(\n    (s: number) => clamp(Math.round(((s - 1) / (top - 1)) * cells), 0, cells),\n    [cells, top],\n  );\n\n  const mark = useCallback(\n    (s: number) => {\n      const next = toStep(s);\n      if (stepRef.current === next) return;\n      stepRef.current = next;\n      setStep(next);\n    },\n    [toStep],\n  );\n\n  const settle = useCallback(\n    (s: number) => {\n      if (timer.current) {\n        clearTimeout(timer.current);\n        timer.current = null;\n      }\n      const next = toStep(s);\n      if (settledRef.current === next) return;\n      settledRef.current = next;\n      setSettled(next);\n    },\n    [toStep],\n  );\n\n  const settleSoon = useCallback(\n    (s: number) => {\n      if (timer.current) clearTimeout(timer.current);\n      timer.current = setTimeout(() => {\n        timer.current = null;\n        settle(s);\n      }, 220);\n    },\n    [settle],\n  );\n\n  const limit = useCallback((s: number) => {\n    const frame = frameRef.current;\n    const content = contentRef.current;\n    if (!frame || !content) return { mx: 0, my: 0 };\n    return {\n      mx: Math.max(0, (content.offsetWidth * s - frame.clientWidth) / 2),\n      my: Math.max(0, (content.offsetHeight * s - frame.clientHeight) / 2),\n    };\n  }, []);\n\n  const place = useCallback(\n    (s: number, nx: number, ny: number) => {\n      const { mx, my } = limit(s);\n      scale.set(s);\n      x.set(clamp(nx, -mx, mx));\n      y.set(clamp(ny, -my, my));\n      mark(s);\n    },\n    [limit, mark, scale, x, y],\n  );\n\n  const glide = useCallback(\n    (s: number, nx: number, ny: number, spring: Spring = CELL) => {\n      const { mx, my } = limit(s);\n      const tx = clamp(nx, -mx, mx);\n      const ty = clamp(ny, -my, my);\n      if (reduced) {\n        scale.set(s);\n        x.set(tx);\n        y.set(ty);\n      } else {\n        animate(scale, s, spring);\n        animate(x, tx, spring);\n        animate(y, ty, spring);\n      }\n      mark(s);\n      settle(s);\n    },\n    [limit, mark, reduced, scale, settle, x, y],\n  );\n\n  const reset = useCallback(() => {\n    glide(1, 0, 0, HOME);\n  }, [glide]);\n\n  const zoomAt = useCallback(\n    (next: number, cx: number, cy: number, animated: boolean) => {\n      const frame = frameRef.current;\n      if (!frame) return;\n      const r = frame.getBoundingClientRect();\n      const px = cx - (r.left + r.width / 2);\n      const py = cy - (r.top + r.height / 2);\n      const s0 = scale.get();\n      const ax = (px - x.get()) / s0;\n      const ay = (py - y.get()) / s0;\n      const s = clamp(next, 1, top);\n      const nx = px - ax * s;\n      const ny = py - ay * s;\n      if (animated) {\n        glide(s, nx, ny, s <= 1 ? HOME : CELL);\n        return;\n      }\n      place(s, nx, ny);\n      settleSoon(s);\n    },\n    [glide, place, scale, settleSoon, top, x, y],\n  );\n\n  const finish = useCallback(() => {\n    const s0 = scale.get();\n    if (s0 < SNAP_HOME) reset();\n    else settle(s0);\n  }, [reset, scale, settle]);\n\n  const release = (e: React.PointerEvent) => {\n    const held = drag.current;\n    if (!held || held.id !== e.pointerId) return;\n    drag.current = null;\n    const moved = Math.hypot(e.clientX - held.from.x, e.clientY - held.from.y);\n    if (moved < SLOP && !onContent.current && scale.get() <= NEAR_HOME) {\n      dismiss.current?.();\n      return;\n    }\n    finish();\n  };\n\n  const cancel = (e: React.PointerEvent) => {\n    const held = drag.current;\n    if (!held || held.id !== e.pointerId) return;\n    drag.current = null;\n    finish();\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    const frame = frameRef.current;\n    if (!frame || disabled) return;\n    const r = frame.getBoundingClientRect();\n    const cx = r.left + r.width / 2;\n    const cy = r.top + r.height / 2;\n    const s0 = scale.get();\n\n    if (e.key === \"+\" || e.key === \"=\") {\n      e.preventDefault();\n      zoomAt(s0 * KEY_ZOOM, cx, cy, true);\n      return;\n    }\n    if (e.key === \"-\" || e.key === \"_\") {\n      e.preventDefault();\n      zoomAt(s0 / KEY_ZOOM, cx, cy, true);\n      return;\n    }\n    if (e.key === \"0\") {\n      e.preventDefault();\n      reset();\n      return;\n    }\n    if (e.key === \"Escape\" && s0 > NEAR_HOME) {\n      e.preventDefault();\n      e.stopPropagation();\n      reset();\n      return;\n    }\n    if (s0 > NEAR_HOME && e.key.startsWith(\"Arrow\")) {\n      e.preventDefault();\n      const dx =\n        e.key === \"ArrowLeft\" ? KEY_PAN : e.key === \"ArrowRight\" ? -KEY_PAN : 0;\n      const dy =\n        e.key === \"ArrowUp\" ? KEY_PAN : e.key === \"ArrowDown\" ? -KEY_PAN : 0;\n      glide(s0, x.get() + dx, y.get() + dy);\n    }\n  };\n\n  const bind = {\n    onPointerDown: (e: React.PointerEvent) => {\n      if (disabled) return;\n      if (e.pointerType === \"mouse\" && e.button !== 0) return;\n      const content = contentRef.current;\n      onContent.current = content ? content.contains(e.target as Node) : false;\n      e.currentTarget.setPointerCapture?.(e.pointerId);\n      drag.current = {\n        id: e.pointerId,\n        from: { x: e.clientX, y: e.clientY },\n        x: x.get(),\n        y: y.get(),\n      };\n    },\n    onPointerMove: (e: React.PointerEvent) => {\n      const held = drag.current;\n      if (!held || held.id !== e.pointerId) return;\n      if (scale.get() <= 1) return;\n      place(\n        scale.get(),\n        held.x + (e.clientX - held.from.x),\n        held.y + (e.clientY - held.from.y),\n      );\n    },\n    onPointerUp: release,\n    onPointerCancel: cancel,\n    onLostPointerCapture: cancel,\n    onDoubleClick: (e: React.MouseEvent) => {\n      if (disabled) return;\n      zoomAt(\n        scale.get() > SNAP_HOME ? 1 : Math.min(TOGGLE, top),\n        e.clientX,\n        e.clientY,\n        true,\n      );\n    },\n    onKeyDown,\n  };\n\n  useEffect(() => {\n    const frame = frameRef.current;\n    if (!frame) return;\n    const onWheel = (e: WheelEvent) => {\n      if (disabled) return;\n      e.preventDefault();\n      zoomAt(\n        scale.get() * Math.exp(-e.deltaY / WHEEL_RATE),\n        e.clientX,\n        e.clientY,\n        false,\n      );\n    };\n    frame.addEventListener(\"wheel\", onWheel, { passive: false });\n    return () => frame.removeEventListener(\"wheel\", onWheel);\n  }, [disabled, scale, zoomAt]);\n\n  useEffect(() => {\n    const bail = () => {\n      drag.current = null;\n    };\n    window.addEventListener(\"blur\", bail);\n    return () => {\n      window.removeEventListener(\"blur\", bail);\n      if (timer.current) clearTimeout(timer.current);\n    };\n  }, []);\n\n  return {\n    frameRef,\n    contentRef,\n    bind,\n    scale,\n    x,\n    y,\n    step,\n    steps: cells,\n    zoom: 1 + (step / cells) * (top - 1),\n    settledZoom: 1 + (settled / cells) * (top - 1),\n    zoomed: step > 0,\n    reset,\n    zoomAt,\n  };\n}\n\nexport type LightboxProps = {\n  open: boolean;\n  onClose: () => void;\n  src: string;\n  alt: string;\n  originRef?: React.RefObject<HTMLElement | null>;\n  caption?: string;\n  width?: number;\n  height?: number;\n  maxScale?: number;\n  className?: string;\n};\n\ntype Landing = { dx: number; dy: number; s: number; o: number; r: number };\n\nfunction Stage({\n  onClose,\n  src,\n  alt,\n  originRef,\n  caption,\n  width,\n  height,\n  maxScale = 4,\n  className = \"\",\n}: LightboxProps) {\n  const reduced = useReducedMotion();\n  const titleId = useId();\n  const hintId = useId();\n\n  const fx = useMotionValue(0);\n  const fy = useMotionValue(0);\n  const fs = useMotionValue(1);\n  const fo = useMotionValue(0);\n  const fr = useMotionValue(14);\n\n  const {\n    frameRef,\n    contentRef,\n    bind,\n    scale,\n    x,\n    y,\n    zoomed,\n    settledZoom,\n    reset,\n    zoomAt,\n  } = useLightbox({ maxScale, onDismiss: onClose });\n\n  const shellRef = useRef<HTMLDivElement>(null);\n\n  const toggleZoom = useCallback(() => {\n    const frame = frameRef.current;\n    if (!frame) return;\n    if (zoomed) {\n      reset();\n      return;\n    }\n    const r = frame.getBoundingClientRect();\n    zoomAt(\n      Math.min(TOGGLE, Math.max(1.1, maxScale)),\n      r.left + r.width / 2,\n      r.top + r.height / 2,\n      true,\n    );\n  }, [frameRef, maxScale, reset, zoomAt, zoomed]);\n\n  const landing = useCallback((): Landing => {\n    const frame = frameRef.current;\n    const content = contentRef.current;\n    const origin = originRef?.current;\n    if (frame && content && origin && content.offsetWidth > 0) {\n      const r = frame.getBoundingClientRect();\n      const o = origin.getBoundingClientRect();\n      if (o.width > 0) {\n        const s = o.width / content.offsetWidth;\n        const rad =\n          Number.parseFloat(getComputedStyle(origin).borderTopLeftRadius) || 9;\n        return {\n          dx: o.left + o.width / 2 - (r.left + r.width / 2),\n          dy: o.top + o.height / 2 - (r.top + r.height / 2),\n          s,\n          o: 1,\n          r: rad / s,\n        };\n      }\n    }\n    return { dx: 0, dy: 10, s: 0.97, o: 0, r: 14 };\n  }, [contentRef, frameRef, originRef]);\n\n  useLayoutEffect(() => {\n    if (reduced) {\n      fx.set(0);\n      fy.set(0);\n      fs.set(1);\n      fo.set(1);\n      fr.set(14);\n      return;\n    }\n    const d = landing();\n    fx.set(d.dx);\n    fy.set(d.dy);\n    fs.set(d.s);\n    fo.set(d.o);\n    fr.set(d.r);\n\n    const runs = [\n      animate(fx, 0, HOME),\n      animate(fy, 0, HOME),\n      animate(fs, 1, HOME),\n      animate(fo, 1, HOME),\n      animate(fr, 14, HOME),\n    ];\n    return () => runs.forEach((r) => r.stop());\n  }, [fo, fr, fs, fx, fy, landing, reduced]);\n\n  const away = useCallback(() => {\n    if (reduced) return { opacity: 0, transition: { duration: 0.12 } };\n    const d = landing();\n    animate(fr, d.r, HOME);\n    return {\n      x: d.dx,\n      y: d.dy,\n      scale: d.s,\n      opacity: d.o,\n      filter: \"blur(4px)\",\n      transition: HOME,\n    };\n  }, [fr, landing, reduced]);\n\n  const unwind = useCallback(\n    () => ({\n      x: 0,\n      y: 0,\n      scale: 1,\n      transition: reduced ? { duration: 0 } : HOME,\n    }),\n    [reduced],\n  );\n\n  useEffect(() => {\n    const frame = frameRef.current;\n    const previous =\n      document.activeElement instanceof HTMLElement\n        ? document.activeElement\n        : null;\n    const body = document.body;\n    const overflow = body.style.overflow;\n    const padding = body.style.paddingRight;\n    const gap = window.innerWidth - document.documentElement.clientWidth;\n    const base = Number.parseFloat(getComputedStyle(body).paddingRight) || 0;\n    body.style.overflow = \"hidden\";\n    if (gap > 0) body.style.paddingRight = `${base + gap}px`;\n    frame?.focus({ preventScroll: true });\n    return () => {\n      body.style.overflow = overflow;\n      body.style.paddingRight = padding;\n      if (previous?.isConnected) previous.focus({ preventScroll: true });\n    };\n  }, [frameRef]);\n\n  useEffect(() => {\n    const shell = shellRef.current;\n    if (!shell) return;\n\n    const onKeyDown = (e: KeyboardEvent) => {\n      if (e.key === \"Escape\") {\n        if (zoomed) return;\n        e.preventDefault();\n        onClose();\n        return;\n      }\n      if (e.key !== \"Tab\") return;\n      const nodes = Array.from(\n        shell.querySelectorAll<HTMLElement>('[data-lightbox-focus=\"1\"]'),\n      );\n      if (nodes.length === 0) return;\n      e.preventDefault();\n      const here =\n        document.activeElement instanceof HTMLElement\n          ? nodes.indexOf(document.activeElement)\n          : -1;\n      const next = e.shiftKey\n        ? here <= 0\n          ? nodes.length - 1\n          : here - 1\n        : here === -1 || here === nodes.length - 1\n          ? 0\n          : here + 1;\n      nodes[next]?.focus();\n    };\n\n    shell.addEventListener(\"keydown\", onKeyDown);\n    return () => shell.removeEventListener(\"keydown\", onKeyDown);\n  }, [onClose, zoomed]);\n\n  return (\n    <div\n      ref={shellRef}\n      role=\"dialog\"\n      aria-modal=\"true\"\n      aria-labelledby={titleId}\n      className={`fixed inset-0 z-50 ${className}`}\n    >\n      <motion.div\n        aria-hidden\n        initial={{ opacity: 0 }}\n        animate={{ opacity: 1 }}\n        exit={{\n          opacity: 0,\n          transition: reduced ? { duration: 0 } : { duration: 0.3, ease: EASE },\n        }}\n        transition={reduced ? { duration: 0 } : VEIL}\n        className=\"absolute inset-0 bg-stone-950/80\"\n      />\n      <div\n        ref={frameRef}\n        data-lightbox-focus=\"1\"\n        tabIndex={-1}\n        role=\"group\"\n        aria-labelledby={titleId}\n        aria-describedby={hintId}\n        style={{ touchAction: \"none\", WebkitTouchCallout: \"none\" }}\n        className={`absolute inset-0 overflow-hidden outline-none select-none focus-visible:shadow-[inset_0_0_0_1px_#93B0FF] ${\n          zoomed ? \"cursor-grab active:cursor-grabbing\" : \"cursor-zoom-in\"\n        }`}\n        {...bind}\n      >\n        <motion.div\n          style={{ x: fx, y: fy, scale: fs, opacity: fo }}\n          initial={reduced ? false : { filter: \"blur(6px)\" }}\n          animate={{ filter: \"blur(0px)\" }}\n          variants={{ away }}\n          exit=\"away\"\n          transition={\n            reduced\n              ? { duration: 0 }\n              : { filter: { duration: 0.35, ease: EASE } }\n          }\n          className=\"absolute inset-0 flex items-center justify-center p-4 sm:p-14\"\n        >\n          <motion.img\n            ref={contentRef}\n            src={src}\n            alt={alt}\n            width={width}\n            height={height}\n            draggable={false}\n            style={{ x, y, scale, borderRadius: fr }}\n            variants={{ away: unwind }}\n            exit=\"away\"\n            className=\"max-h-full max-w-full object-contain\"\n          />\n        </motion.div>\n      </div>\n      <motion.div\n        initial={{ opacity: 0 }}\n        animate={{ opacity: 1 }}\n        exit={{ opacity: 0, transition: reduced ? { duration: 0 } : EXIT }}\n        transition={reduced ? { duration: 0 } : VEIL}\n        className=\"pointer-events-none absolute inset-0 flex items-start justify-between gap-3 p-3 sm:p-4\"\n      >\n        <p\n          id={titleId}\n          className=\"pointer-events-auto max-w-[65%] truncate rounded-[9px] border border-stone-200 bg-white px-2.5 py-1.5 text-[12.5px] text-stone-700 dark:border-white/[0.16] dark:bg-[#1D1D1A] dark:text-stone-200\"\n        >\n          {caption ?? alt}\n        </p>\n        <div className=\"pointer-events-auto flex items-center gap-2\">\n          <button\n            data-lightbox-focus=\"1\"\n            type=\"button\"\n            onClick={toggleZoom}\n            aria-label={zoomed ? \"Zoom out\" : \"Zoom in\"}\n            className={CHROME_BUTTON}\n          >\n            <svg\n              viewBox=\"0 0 256 256\"\n              className=\"size-[15px]\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth={16}\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              aria-hidden\n            >\n              <circle cx=\"116\" cy=\"116\" r=\"84\" />\n              <path d=\"M175.4 175.4 224 224M84 116h64\" />\n              <motion.path\n                d=\"M116 84v64\"\n                initial={false}\n                animate={{ opacity: zoomed ? 0 : 1 }}\n                transition={reduced ? { duration: 0 } : GLYPH}\n              />\n            </svg>\n          </button>\n          <button\n            data-lightbox-focus=\"1\"\n            type=\"button\"\n            onClick={onClose}\n            aria-label=\"Close\"\n            className={CHROME_BUTTON}\n          >\n            <svg\n              viewBox=\"0 0 256 256\"\n              className=\"size-[15px]\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth={16}\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              aria-hidden\n            >\n              <path d=\"M200 56 56 200M200 200 56 56\" />\n            </svg>\n          </button>\n        </div>\n      </motion.div>\n      <p id={hintId} className=\"sr-only\">\n        Scroll to zoom toward the pointer, or press plus and minus. Drag or use\n        the arrow keys to pan, and double-click to switch between fit and\n        close-up. Press zero to return to the starting frame; Escape returns\n        home first, then closes.\n      </p>\n      <p role=\"status\" className=\"sr-only\">\n        Zoom {settledZoom.toFixed(1)} times\n      </p>\n    </div>\n  );\n}\n\nexport function Lightbox(props: LightboxProps) {\n  const [mounted, setMounted] = useState(false);\n\n  useEffect(() => {\n    setMounted(true);\n  }, []);\n\n  if (!mounted) return null;\n\n  return createPortal(\n    <AnimatePresence>\n      {props.open ? <Stage key=\"lightbox\" {...props} /> : null}\n    </AnimatePresence>,\n    document.body,\n  );\n}\n"}]}