{"slug":"image-viewer","name":"Image Viewer","description":"A composable React image viewer for galleries and attachment previews, with animated lightbox variants, custom thumbnails and controls, swipe and keyboard navigation, zoom and pan, accessible focus management, and controlled or uncontrolled selection.","category":"motion","source_url":"https://beui.dev/r/image-viewer/raw","detail_url":"https://beui.dev/r/image-viewer","raw_url":"https://beui.dev/r/image-viewer/raw","page_url":"https://beui.dev/components/motion/image-viewer","markdown_url":"https://beui.dev/components/motion/image-viewer.md","published_at":"2026-10-02","updated_at":"2026-10-02","dependencies":["clsx","lucide-react","motion","react","react-dom","tailwind-merge"],"internal":["@/lib/ease","@/lib/hooks/use-modal-scope","@/lib/presence-gate","@/lib/utils"],"files":[{"path":"components/motion/morphing-lightbox.tsx","type":"component","content":"\"use client\";\n// beui.dev/components/motion/image-viewer\n\nimport { ChevronLeft, ChevronRight, Minus, Plus, X } from \"lucide-react\";\nimport {\n  type HTMLMotionProps,\n  type Variants,\n  AnimatePresence,\n  LayoutGroup,\n  motion,\n  useMotionValue,\n  useIsPresent,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  type ComponentPropsWithRef,\n  type ReactNode,\n  createContext,\n  useContext,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport {\n  EASE_DRAWER,\n  EASE_OUT,\n  SPRING_LAYOUT,\n  SPRING_PRESS,\n} from \"@/lib/ease\";\nimport { useModalScope } from \"@/lib/hooks/use-modal-scope\";\nimport { PresenceGate } from \"@/lib/presence-gate\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface LightboxImage {\n  id: string;\n  src: string;\n  alt: string;\n  /** Intrinsic dimensions preserve aspect ratio through the thumbnail morph. */\n  width: number;\n  height: number;\n  caption?: string;\n}\n\nexport interface MorphingLightboxProps {\n  images: LightboxImage[];\n  value?: string | null;\n  defaultValue?: string | null;\n  onValueChange?: (id: string | null) => void;\n  label?: string;\n  className?: string;\n  thumbnailClassName?: string;\n  renderCaption?: (image: LightboxImage) => ReactNode;\n  /** Compose gallery and viewer parts instead of the default layout. */\n  children?: ReactNode;\n}\n\ntype ImageSwap = { direction: -1 | 1; reduce: boolean };\n// Paging needs a definite arrival; an overdamped panel spring creeps through\n// the last few pixels before snapping to its rest threshold.\nconst IMAGE_SLIDE_TRANSITION = {\n  type: \"tween\",\n  duration: 0.28,\n  ease: EASE_DRAWER,\n} as const;\n// Use Motion's x channel so the final slide position is painted in the same\n// frame as its animation. A native transform animation can briefly expose its\n// initial inline transform when it releases ownership at completion.\nconst imageSwapVariants: Variants = {\n  enter: ({ direction, reduce }: ImageSwap) => ({\n    opacity: reduce ? 0 : 1,\n    x: `${reduce ? 0 : direction * 100}%`,\n  }),\n  visible: ({ reduce }: ImageSwap) => ({\n    opacity: 1,\n    x: \"0%\",\n    transition: reduce\n      ? { duration: 0.1, ease: EASE_OUT }\n      : IMAGE_SLIDE_TRANSITION,\n  }),\n  exit: ({ direction, reduce }: ImageSwap) => ({\n    opacity: reduce ? 0 : 1,\n    x: `${reduce ? 0 : direction * -100}%`,\n    transition: reduce\n      ? { duration: 0.1, ease: EASE_OUT }\n      : IMAGE_SLIDE_TRANSITION,\n  }),\n};\n\nconst controlClass =\n  \"inline-flex size-10 shrink-0 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white disabled:opacity-30\";\n\nexport type ImageViewerImage = LightboxImage;\nexport type ImageViewerProps = MorphingLightboxProps;\n\ntype ViewerContextValue = {\n  images: LightboxImage[];\n  value: string | null;\n  image: LightboxImage | undefined;\n  index: number;\n  label: string;\n  groupId: string;\n  reduce: boolean;\n  renderCaption?: (image: LightboxImage) => ReactNode;\n  select: (id: string | null) => void;\n  move: (direction: -1 | 1) => void;\n};\nconst ViewerContext = createContext<ViewerContextValue | null>(null);\n\nfunction useViewerContext(part: string) {\n  const context = useContext(ViewerContext);\n  if (!context) throw new Error(`${part} must be used within ImageViewer.`);\n  return context;\n}\n\n/** Shared selection and navigation for custom gallery and viewer controls. */\nexport function useImageViewer() {\n  const { images, value, image, index, select, move } =\n    useViewerContext(\"useImageViewer\");\n  return {\n    images,\n    value,\n    image,\n    index,\n    hasPrevious: index > 0,\n    hasNext: index >= 0 && index < images.length - 1,\n    select,\n    close: () => select(null),\n    previous: () => move(-1),\n    next: () => move(1),\n  };\n}\n\n/** A composable image gallery with a focus-managed, thumbnail-connected viewer. */\nexport function ImageViewer({\n  images,\n  value: controlledValue,\n  defaultValue = null,\n  onValueChange,\n  label = \"Image gallery\",\n  className,\n  thumbnailClassName,\n  renderCaption,\n  children,\n}: ImageViewerProps) {\n  const [internalValue, setInternalValue] = useState(defaultValue);\n  const value = controlledValue === undefined ? internalValue : controlledValue;\n  const index = images.findIndex((image) => image.id === value);\n  const image = images[index];\n  const groupId = useId();\n  const reduce = useReducedMotion() ?? false;\n  if (new Set(images.map((item) => item.id)).size !== images.length)\n    throw new Error(\"ImageViewer requires unique image ids.\");\n  if (\n    images.some(\n      (item) =>\n        !Number.isFinite(item.width) ||\n        !Number.isFinite(item.height) ||\n        item.width <= 0 ||\n        item.height <= 0,\n    )\n  )\n    throw new Error(\"ImageViewer requires positive image dimensions.\");\n  const select = (id: string | null) => {\n    if (controlledValue === undefined) setInternalValue(id);\n    onValueChange?.(id);\n  };\n  // Drop removed identities instead of letting a re-added image reopen a session.\n  if (controlledValue === undefined && internalValue !== null && index < 0)\n    setInternalValue(null);\n  const move = (direction: -1 | 1) => {\n    const next = images[index + direction];\n    if (image && next) select(next.id);\n  };\n  return (\n    <ViewerContext.Provider\n      value={{\n        images,\n        value,\n        image,\n        index,\n        label,\n        groupId,\n        reduce,\n        renderCaption,\n        select,\n        move,\n      }}\n    >\n      <LayoutGroup id={groupId}>\n        {children === undefined ? (\n          <>\n            <ImageViewerGallery className={className}>\n              {images.map((item) => (\n                <ImageViewerThumbnail\n                  key={item.id}\n                  imageId={item.id}\n                  className={thumbnailClassName}\n                />\n              ))}\n            </ImageViewerGallery>\n            <ImageViewerContent />\n          </>\n        ) : (\n          children\n        )}\n      </LayoutGroup>\n    </ViewerContext.Provider>\n  );\n}\n\n/** Generic name; the original export remains compatible with existing installs. */\nexport { ImageViewer as MorphingLightbox };\n\nexport function ImageViewerGallery({\n  className,\n  ...props\n}: ComponentPropsWithRef<\"section\">) {\n  const { label } = useViewerContext(\"ImageViewerGallery\");\n  return (\n    <section\n      aria-label={label}\n      {...props}\n      className={cn(\"grid w-full grid-cols-2 gap-3 sm:grid-cols-3\", className)}\n    />\n  );\n}\n\nexport interface ImageViewerThumbnailProps\n  extends Omit<HTMLMotionProps<\"button\">, \"children\"> {\n  imageId: string;\n  imageClassName?: string;\n  children?: ReactNode;\n}\n\n/** The image keeps its shared-layout identity when the trigger is customised. */\nexport function ImageViewerThumbnail({\n  imageId,\n  className,\n  imageClassName,\n  children,\n  onClick,\n  disabled,\n  ...props\n}: ImageViewerThumbnailProps) {\n  const { images, value, groupId, reduce, select } = useViewerContext(\n    \"ImageViewerThumbnail\",\n  );\n  const image = images.find((item) => item.id === imageId);\n  if (!image)\n    throw new Error(`ImageViewerThumbnail: unknown image id \"${imageId}\".`);\n  return (\n    <motion.button\n      type=\"button\"\n      aria-label={`Open ${image.alt}`}\n      whileTap={reduce ? undefined : { scale: 0.98 }}\n      transition={SPRING_PRESS}\n      {...props}\n      disabled={disabled}\n      aria-haspopup=\"dialog\"\n      aria-expanded={image.id === value}\n      onClick={(event) => {\n        onClick?.(event);\n        if (!event.defaultPrevented && !disabled) select(image.id);\n      }}\n      className={cn(\n        \"relative overflow-hidden rounded-2xl bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n        className,\n      )}\n    >\n      {/* biome-ignore lint/performance/noImgElement: Copy-paste registry images must work outside Next.js. */}\n      <motion.img\n        layoutId={reduce ? undefined : `${groupId}-${image.id}`}\n        src={image.src}\n        alt={image.alt}\n        width={image.width}\n        height={image.height}\n        loading=\"lazy\"\n        draggable={false}\n        transition={SPRING_LAYOUT}\n        className={cn(\"block h-auto w-full\", imageClassName)}\n      />\n      {children}\n    </motion.button>\n  );\n}\n\nexport function ImageViewerCounter({\n  className,\n  ...props\n}: ComponentPropsWithRef<\"span\">) {\n  const { index, images } = useViewerContext(\"ImageViewerCounter\");\n  return (\n    <span\n      aria-live=\"polite\"\n      {...props}\n      className={cn(\"text-xs tabular-nums\", className)}\n    >\n      {index + 1} / {images.length}\n    </span>\n  );\n}\n\nexport function ImageViewerCaption({\n  className,\n  children,\n  ...props\n}: ComponentPropsWithRef<\"div\">) {\n  const { image, renderCaption } = useViewerContext(\"ImageViewerCaption\");\n  return (\n    <div\n      aria-live=\"polite\"\n      {...props}\n      data-lightbox-content=\"\"\n      className={cn(\"min-w-0 text-center text-sm\", className)}\n    >\n      {children ??\n        (image &&\n          (renderCaption\n            ? renderCaption(image)\n            : (image.caption ?? image.alt)))}\n    </div>\n  );\n}\n\ntype ViewerButtonProps = ComponentPropsWithRef<\"button\">;\nfunction ViewerButton({\n  action,\n  className,\n  children,\n  disabled,\n  onClick,\n  ...props\n}: ViewerButtonProps & { action: \"close\" | \"previous\" | \"next\" }) {\n  const { index, images, select, move } = useViewerContext(\n    \"ImageViewer controls\",\n  );\n  const unavailable =\n    action === \"previous\"\n      ? index <= 0\n      : action === \"next\"\n        ? index < 0 || index >= images.length - 1\n        : false;\n  const Icon =\n    action === \"close\" ? X : action === \"previous\" ? ChevronLeft : ChevronRight;\n  return (\n    <button\n      type=\"button\"\n      aria-label={\n        action === \"close\"\n          ? \"Close viewer\"\n          : action === \"previous\"\n            ? \"Previous image\"\n            : \"Next image\"\n      }\n      {...props}\n      disabled={disabled || unavailable}\n      onClick={(event) => {\n        onClick?.(event);\n        if (!event.defaultPrevented) {\n          if (action === \"close\") select(null);\n          else move(action === \"previous\" ? -1 : 1);\n        }\n      }}\n      className={cn(controlClass, className)}\n    >\n      {children ?? <Icon size={18} aria-hidden=\"true\" />}\n    </button>\n  );\n}\nexport function ImageViewerClose(props: ViewerButtonProps) {\n  return <ViewerButton {...props} action=\"close\" />;\n}\nexport function ImageViewerPrevious(props: ViewerButtonProps) {\n  return <ViewerButton {...props} action=\"previous\" />;\n}\nexport function ImageViewerNext(props: ViewerButtonProps) {\n  return <ViewerButton {...props} action=\"next\" />;\n}\n\nexport interface ImageViewerContentProps {\n  className?: string;\n  /** Custom header; defaults to the counter and close control. */\n  header?: ReactNode;\n  /** Custom footer; defaults to navigation and the image caption. */\n  children?: ReactNode;\n}\n\n/** Owns the portal, focus scope, swipe/zoom frame and exit interaction gate. */\nexport function ImageViewerContent({\n  className,\n  header,\n  children,\n}: ImageViewerContentProps) {\n  const context = useViewerContext(\"ImageViewerContent\");\n  const { image, index, label, groupId, reduce, select, move } = context;\n  const [mounted, setMounted] = useState(false);\n  const portal = useRef<HTMLDivElement>(null);\n  const panel = useRef<HTMLDivElement>(null);\n  useEffect(() => {\n    setMounted(true);\n  }, []);\n  useModalScope(mounted && Boolean(image), portal, panel, () => select(null));\n  const selectedId = image?.id;\n  const previousId = useRef<string | undefined>(undefined);\n  useLayoutEffect(() => {\n    const previous = previousId.current;\n    previousId.current = mounted ? selectedId : undefined;\n    if (!mounted || !selectedId || !previous || !panel.current) return;\n    const focused = document.activeElement;\n    if (\n      !panel.current.contains(focused) ||\n      (focused instanceof HTMLButtonElement && focused.disabled)\n    ) {\n      (\n        panel.current.querySelector<HTMLButtonElement>(\n          \"button:not(:disabled)\",\n        ) ?? panel.current\n      ).focus({ preventScroll: true });\n    }\n  }, [mounted, selectedId]);\n  if (!mounted) return null;\n  return createPortal(\n    <div ref={portal}>\n      <AnimatePresence>\n        {image && (\n          <PresenceGate>\n            {({ isPresent, gate }) => (\n              <ViewerContext.Provider value={context}>\n                <motion.button\n                  {...gate}\n                  type=\"button\"\n                  tabIndex={-1}\n                  aria-hidden=\"true\"\n                  onClick={() => select(null)}\n                  initial={{ opacity: 0 }}\n                  animate={{ opacity: 1 }}\n                  exit={{ opacity: 0 }}\n                  transition={{ duration: reduce ? 0.1 : 0.18, ease: EASE_OUT }}\n                  className=\"fixed inset-0 z-[100] h-full w-full cursor-default bg-black/90\"\n                />\n                <div\n                  inert={!isPresent}\n                  className=\"pointer-events-none fixed inset-3 z-[101] sm:inset-6\"\n                >\n                  <div\n                    {...gate}\n                    ref={panel}\n                    role=\"dialog\"\n                    aria-modal=\"true\"\n                    aria-label={label}\n                    tabIndex={-1}\n                    className={cn(\n                      \"flex h-full flex-col outline-none\",\n                      className,\n                    )}\n                    onPointerDownCapture={(event) => {\n                      const target = event.target;\n                      if (\n                        target instanceof Element &&\n                        !target.closest(\n                          \"button, a, input, select, textarea, [data-lightbox-content]\",\n                        )\n                      ) {\n                        event.preventDefault();\n                        select(null);\n                      }\n                    }}\n                    onKeyDown={(event) => {\n                      if (\n                        (event.target instanceof Element &&\n                          event.target.closest(\n                            'input, textarea, select, [contenteditable=\"true\"]',\n                          )) ||\n                        event.defaultPrevented ||\n                        event.altKey ||\n                        event.ctrlKey ||\n                        event.metaKey ||\n                        event.shiftKey\n                      )\n                        return;\n                      if (\n                        event.key === \"ArrowLeft\" ||\n                        event.key === \"ArrowRight\"\n                      ) {\n                        event.preventDefault();\n                        move(event.key === \"ArrowLeft\" ? -1 : 1);\n                      }\n                    }}\n                  >\n                    <div\n                      data-lightbox-content=\"\"\n                      className=\"flex items-center justify-between gap-3 pb-3 text-white\"\n                    >\n                      {header === undefined ? (\n                        <>\n                          <ImageViewerCounter />\n                          <ImageViewerClose />\n                        </>\n                      ) : (\n                        header\n                      )}\n                    </div>\n                    <LightboxFrame\n                      image={image}\n                      index={index}\n                      layoutId={reduce ? undefined : `${groupId}-${image.id}`}\n                      reduce={reduce}\n                      onSwipe={move}\n                    />\n                    <div\n                      data-lightbox-content=\"\"\n                      className=\"flex items-center justify-between gap-3 pt-3 text-white\"\n                    >\n                      {children === undefined ? (\n                        <>\n                          <ImageViewerPrevious />\n                          <ImageViewerCaption />\n                          <ImageViewerNext />\n                        </>\n                      ) : (\n                        children\n                      )}\n                    </div>\n                  </div>\n                </div>\n              </ViewerContext.Provider>\n            )}\n          </PresenceGate>\n        )}\n      </AnimatePresence>\n    </div>,\n    document.body,\n  );\n}\n\nfunction LightboxFrame({\n  image,\n  index,\n  layoutId,\n  reduce,\n  onSwipe,\n}: {\n  image: LightboxImage;\n  index: number;\n  layoutId?: string;\n  reduce: boolean;\n  onSwipe: (direction: -1 | 1) => void;\n}) {\n  const [zoom, setZoom] = useState(false);\n  const [failed, setFailed] = useState(false);\n  const session = `${image.id}-${image.src}`;\n  const [selection, setSelection] = useState<{\n    session: string;\n    index: number;\n    direction: -1 | 1;\n  }>({ session, index, direction: 1 });\n  const changed = selection.session !== session;\n  const direction = changed\n    ? index < selection.index\n      ? -1\n      : 1\n    : selection.direction;\n  // Keep the shared-layout frame mounted; only the image layer changes identity.\n  if (changed) {\n    setSelection({ session, index, direction });\n    setZoom(false);\n    setFailed(false);\n  }\n  const swipe = useRef<{ x: number; y: number } | null>(null);\n  const present = useIsPresent();\n  const bounds = useRef<HTMLDivElement>(null);\n  const imageBox = useRef<HTMLDivElement>(null);\n  const x = useMotionValue(0);\n  const y = useMotionValue(0);\n  const [pan, setPan] = useState({ x: 0, y: 0 });\n  const panHelp = useId();\n  const positionSession = useRef(session);\n  useLayoutEffect(() => {\n    if (positionSession.current === session) return;\n    positionSession.current = session;\n    x.set(0);\n    y.set(0);\n    swipe.current = null;\n  }, [session, x, y]);\n  useLayoutEffect(() => {\n    const measure = () => {\n      if (!bounds.current || !imageBox.current) return;\n      const next = {\n        x: Math.max(\n          0,\n          (imageBox.current.clientWidth * 2 - bounds.current.clientWidth) / 2,\n        ),\n        y: Math.max(\n          0,\n          (imageBox.current.clientHeight * 2 - bounds.current.clientHeight) / 2,\n        ),\n      };\n      setPan(next);\n      x.set(Math.max(-next.x, Math.min(next.x, x.get())));\n      y.set(Math.max(-next.y, Math.min(next.y, y.get())));\n    };\n    measure();\n    const observer = new ResizeObserver(measure);\n    if (bounds.current) observer.observe(bounds.current);\n    if (imageBox.current) observer.observe(imageBox.current);\n    return () => observer.disconnect();\n  }, [x, y]);\n  return (\n    <div className=\"relative flex min-h-0 flex-1 items-center justify-center\">\n      <div\n        ref={bounds}\n        className=\"flex h-full w-full items-center justify-center overflow-hidden rounded-lg\"\n      >\n        <motion.div\n          ref={imageBox}\n          data-lightbox-content=\"\"\n          layoutId={layoutId}\n          transition={SPRING_LAYOUT}\n          className=\"relative flex max-h-full max-w-full items-center justify-center\"\n          style={{\n            aspectRatio: `${image.width} / ${image.height}`,\n            width: `min(100%, calc((100dvh - 160px) * ${image.width / image.height}))`,\n          }}\n        >\n          <div className={cn(\"absolute inset-0\", !zoom && \"overflow-hidden\")}>\n            <AnimatePresence initial={false} custom={{ direction, reduce }}>\n              <PresenceGate key={session}>\n                {({ isPresent: slidePresent, gate }) => (\n                  <motion.div\n                    {...gate}\n                    custom={{ direction, reduce }}\n                    variants={imageSwapVariants}\n                    initial=\"enter\"\n                    animate=\"visible\"\n                    exit=\"exit\"\n                    className=\"absolute inset-0\"\n                  >\n                    {/* biome-ignore lint/performance/noImgElement: Copy-paste registry images must work outside Next.js. */}\n                    <motion.img\n                      src={image.src}\n                      alt={image.alt}\n                      width={image.width}\n                      height={image.height}\n                      draggable={false}\n                      onError={() => {\n                        if (slidePresent && present) setFailed(true);\n                      }}\n                      // Keep Motion's drag/layout registration mounted across zoom changes.\n                      drag\n                      dragListener={zoom}\n                      dragConstraints={{\n                        left: -pan.x,\n                        right: pan.x,\n                        top: -pan.y,\n                        bottom: pan.y,\n                      }}\n                      dragElastic={0.08}\n                      dragMomentum={false}\n                      animate={{\n                        scale: zoom && present ? 2 : 1,\n                        ...(!present ? { x: 0, y: 0 } : {}),\n                      }}\n                      transition={reduce ? { duration: 0 } : SPRING_LAYOUT}\n                      onPointerDown={(event) => {\n                        // Swiping the image keeps keyboard focus on the controls.\n                        event.preventDefault();\n                        if (!zoom && event.isPrimary)\n                          swipe.current = {\n                            x: event.clientX,\n                            y: event.clientY,\n                          };\n                      }}\n                      onPointerUp={(event) => {\n                        const start = swipe.current;\n                        swipe.current = null;\n                        if (!start || zoom) return;\n                        const dx = event.clientX - start.x;\n                        const dy = event.clientY - start.y;\n                        if (\n                          Math.abs(dx) > 50 &&\n                          Math.abs(dx) > Math.abs(dy) * 1.5\n                        )\n                          onSwipe(dx < 0 ? 1 : -1);\n                      }}\n                      onPointerCancel={() => {\n                        swipe.current = null;\n                      }}\n                      className=\"block h-full w-full select-none object-contain\"\n                      style={{\n                        x,\n                        y,\n                        touchAction: zoom ? \"none\" : \"pan-y\",\n                        cursor: zoom ? \"grab\" : undefined,\n                      }}\n                    />\n                  </motion.div>\n                )}\n              </PresenceGate>\n            </AnimatePresence>\n          </div>\n          {failed && (\n            <p\n              role=\"status\"\n              className=\"absolute inset-0 flex items-center justify-center bg-neutral-900 p-4 text-center text-sm text-white\"\n            >\n              Unable to load this image.\n            </p>\n          )}\n        </motion.div>\n      </div>\n      <button\n        type=\"button\"\n        aria-label={zoom ? \"Zoom out\" : \"Zoom in\"}\n        aria-pressed={zoom}\n        aria-describedby={panHelp}\n        onKeyDown={(event) => {\n          if (!zoom) return;\n          const dx =\n            event.key === \"ArrowLeft\"\n              ? -40\n              : event.key === \"ArrowRight\"\n                ? 40\n                : 0;\n          const dy =\n            event.key === \"ArrowUp\" ? -40 : event.key === \"ArrowDown\" ? 40 : 0;\n          if (!dx && !dy) return;\n          event.preventDefault();\n          event.stopPropagation();\n          x.set(Math.max(-pan.x, Math.min(pan.x, x.get() + dx)));\n          y.set(Math.max(-pan.y, Math.min(pan.y, y.get() + dy)));\n        }}\n        onClick={() => {\n          x.set(0);\n          y.set(0);\n          setZoom((current) => !current);\n        }}\n        className={cn(controlClass, \"absolute bottom-3 right-3 bg-black/60\")}\n      >\n        {zoom ? (\n          <Minus size={18} aria-hidden=\"true\" />\n        ) : (\n          <Plus size={18} aria-hidden=\"true\" />\n        )}\n      </button>\n      <span id={panHelp} className=\"sr-only\">\n        When zoomed in, drag the image to pan, or keep this control focused and\n        use the arrow keys.\n      </span>\n    </div>\n  );\n}\n"},{"path":"lib/ease.ts","type":"util","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/hooks/use-modal-scope.ts","type":"util","content":"\"use client\";\n\nimport { type RefObject, useEffect, useLayoutEffect, useRef } from \"react\";\n\nconst FOCUSABLE =\n  'button:not(:disabled), a[href], input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex=\"-1\"])';\nconst inertOwners = new Map<\n  HTMLElement,\n  { count: number; previous: boolean }\n>();\nlet scrollOwners = 0;\nlet previousOverflow = \"\";\n\n/** A portal root owns sibling inertness and scroll locking until its open state ends. */\nexport function useModalScope(\n  open: boolean,\n  root: RefObject<HTMLElement | null>,\n  panel: RefObject<HTMLElement | null>,\n  onClose: () => void,\n) {\n  const close = useRef(onClose);\n  useLayoutEffect(() => {\n    close.current = onClose;\n  });\n  useEffect(() => {\n    if (!open || !root.current || !panel.current) return;\n    const returnFocus =\n      document.activeElement instanceof HTMLElement\n        ? document.activeElement\n        : null;\n    const siblings = Array.from(document.body.children).filter(\n      (element): element is HTMLElement =>\n        element instanceof HTMLElement && element !== root.current,\n    );\n    for (const element of siblings) {\n      const owner = inertOwners.get(element) ?? {\n        count: 0,\n        previous: element.inert,\n      };\n      owner.count += 1;\n      inertOwners.set(element, owner);\n      element.inert = true;\n    }\n    if (scrollOwners++ === 0) {\n      previousOverflow = document.body.style.overflow;\n      document.body.style.overflow = \"hidden\";\n    }\n    const focusables = () =>\n      Array.from(\n        panel.current?.querySelectorAll<HTMLElement>(FOCUSABLE) ?? [],\n      ).filter(\n        (element) => element.tabIndex >= 0 && !element.closest(\"[inert]\"),\n      );\n    const focusFirst = () =>\n      (focusables()[0] ?? panel.current)?.focus({ preventScroll: true });\n    focusFirst();\n    const onFocus = (event: FocusEvent) => {\n      if (\n        event.target instanceof Node &&\n        !panel.current?.contains(event.target) &&\n        !root.current?.inert\n      )\n        focusFirst();\n    };\n    const onKey = (event: KeyboardEvent) => {\n      if (root.current?.inert) return;\n      if (event.key === \"Escape\") {\n        event.preventDefault();\n        event.stopPropagation();\n        close.current();\n      }\n      if (event.key !== \"Tab\") return;\n      const elements = focusables();\n      const first = elements[0];\n      const last = elements[elements.length - 1];\n      if (!first || !last) {\n        event.preventDefault();\n        panel.current?.focus();\n      } else if (event.shiftKey && document.activeElement === first) {\n        event.preventDefault();\n        last.focus();\n      } else if (!event.shiftKey && document.activeElement === last) {\n        event.preventDefault();\n        first.focus();\n      }\n    };\n    document.addEventListener(\"focusin\", onFocus);\n    document.addEventListener(\"keydown\", onKey);\n    return () => {\n      document.removeEventListener(\"focusin\", onFocus);\n      document.removeEventListener(\"keydown\", onKey);\n      for (const element of siblings) {\n        const owner = inertOwners.get(element);\n        if (owner && --owner.count === 0) {\n          element.inert = owner.previous;\n          inertOwners.delete(element);\n        }\n      }\n      if (--scrollOwners === 0) document.body.style.overflow = previousOverflow;\n      if (returnFocus?.isConnected && !returnFocus.closest(\"[inert]\"))\n        returnFocus.focus({ preventScroll: true });\n    };\n  }, [open, root, panel]);\n}\n"},{"path":"lib/presence-gate.tsx","type":"util","content":"\"use client\";\n\nimport { useIsPresent } from \"motion/react\";\nimport type { ReactNode } from \"react\";\n\nexport interface PresenceGateRenderProps {\n  /**\n   * False from the render that starts the exit animation onward. An overlay\n   * kept in the tree by `AnimatePresence` is still the topmost thing on the\n   * page, so anything it decides from `open` alone stays true for the whole\n   * exit — this is the boolean that already knows the overlay is leaving.\n   */\n  isPresent: boolean;\n  /**\n   * Spread onto every layer that takes pointer events while the overlay is\n   * open. Interaction releases in the same commit that starts the exit while\n   * the visual exit keeps playing: pointer events stop landing, and `inert`\n   * drops the subtree from focus order, from tab order and from the\n   * accessibility tree — an exiting dialog is not a dialog you can still type\n   * into. A layer that never takes pointer events (a wrapper that only centres\n   * the panel) takes `inert={!isPresent}` alone, so its own\n   * `pointer-events-none` is not overwritten.\n   */\n  gate: {\n    inert: boolean;\n    style: { pointerEvents: \"auto\" | \"none\" };\n  };\n}\n\nexport interface PresenceGateProps {\n  children: (props: PresenceGateRenderProps) => ReactNode;\n}\n\n/**\n * Reads the presence of the subtree it renders and hands it down.\n *\n * `useIsPresent` only answers inside the `AnimatePresence` subtree, and the\n * components that own an overlay render the `AnimatePresence` themselves, so\n * the boolean has to be read one component further down: this is that\n * component, and the render prop is how it reaches the layers.\n */\nexport function PresenceGate({ children }: PresenceGateProps) {\n  const isPresent = useIsPresent();\n\n  return children({\n    isPresent,\n    gate: {\n      inert: !isPresent,\n      style: { pointerEvents: isPresent ? \"auto\" : \"none\" },\n    },\n  });\n}\n"},{"path":"lib/utils.ts","type":"util","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}