{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"morphing-lightbox","type":"registry:component","title":"Image Viewer Morphing","description":"A thumbnail-connected image viewer with composable gallery, thumbnail, content, caption and navigation parts, plus a shared state hook. Expand into the full-size image and return to the thumbnail on close, with swipe navigation, zoom and pan.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/morphing-lightbox.tsx","type":"registry:component","target":"@components/motion/morphing-lightbox.tsx","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":"registry:lib","target":"@lib/ease.ts","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":"registry:hook","target":"@lib/hooks/use-modal-scope.ts","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":"registry:lib","target":"@lib/presence-gate.tsx","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":"registry:lib","target":"@lib/utils.ts","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"}]}