{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "morphing-modal",
  "type": "registry:component",
  "title": "Morphing Modal",
  "description": "Family-app-style modal. A body-portalled panel that morphs its height between inner views with a blur cross-fade and scroll locking through the closing animation. Exit fades stay at their final values until removal.",
  "author": "Saurabh <saurabh10102@gmail.com>",
  "dependencies": [
    "clsx",
    "motion",
    "tailwind-merge"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "components/motion/morphing-modal.tsx",
      "type": "registry:component",
      "target": "@components/motion/morphing-modal.tsx",
      "content": "\"use client\";\n// asharca.github.io/ui/components/motion/morphing-modal\n\nimport {\n  AnimatePresence,\n  motion,\n  useReducedMotion,\n} from \"motion/react\";\nimport { type ReactNode, useEffect, useLayoutEffect, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { EASE_OUT, SPRING_PANEL } from \"@/lib/ease\";\nimport { PresenceGate } from \"@/lib/presence-gate\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface MorphingModalProps {\n  /** Which view is currently shown. `null` closes the modal. */\n  viewId: string | null;\n  onClose: () => void;\n  children: ReactNode;\n  /** \"bottom\" anchors to the viewport bottom (mobile-like). \"center\" centers vertically. */\n  placement?: \"bottom\" | \"center\";\n  className?: string;\n}\n\nexport function MorphingModal({\n  viewId,\n  onClose,\n  children,\n  placement = \"bottom\",\n  className,\n}: MorphingModalProps) {\n  const open = viewId !== null;\n  const reduce = useReducedMotion();\n  const enterY = reduce ? 0 : placement === \"bottom\" ? 40 : 20;\n  const enterScale = reduce ? 1 : 0.97;\n  const [portalReady, setPortalReady] = useState(false);\n  const [backgroundScrollLocked, setBackgroundScrollLocked] = useState(open);\n\n  useEffect(() => setPortalReady(true), []);\n  if (open && !backgroundScrollLocked) setBackgroundScrollLocked(true);\n\n  useLayoutEffect(() => {\n    if (!backgroundScrollLocked) return;\n    const prev = document.body.style.overflow;\n    document.body.style.overflow = \"hidden\";\n    return () => {\n      document.body.style.overflow = prev;\n    };\n  }, [backgroundScrollLocked]);\n\n  if (!portalReady) return null;\n\n  // Keep viewport coordinates independent of transformed/clipped consumers.\n  // Fixed siblings retain the inset, non-interactive panel positioning layer.\n  // CSS variables keep the final paint values in Motion's render loop. Motion\n  // 11 cancels WAAPI before its final inline-style write, flashing the old value\n  // for a frame in Chrome (including while an exiting sibling is still mounted).\n  return createPortal(\n    <AnimatePresence\n      initial={false}\n      onExitComplete={() => {\n        if (!open) setBackgroundScrollLocked(false);\n      }}\n    >\n      {open ? (\n        <PresenceGate key=\"backdrop\">\n          {({ gate }) => (\n            <motion.button\n              type=\"button\"\n              aria-label=\"Close modal\"\n              initial={{ \"--modal-opacity\": 0 }}\n              animate={{ \"--modal-opacity\": 1 }}\n              exit={{ \"--modal-opacity\": 0 }}\n              transition={{ duration: 0.2, ease: EASE_OUT }}\n              {...gate}\n              onClick={onClose}\n              className=\"pointer-events-auto fixed inset-0 z-[80] bg-background/5 [backdrop-filter:blur(14px)_saturate(140%)] [-webkit-backdrop-filter:blur(14px)_saturate(140%)] [opacity:var(--modal-opacity)]\"\n            />\n          )}\n        </PresenceGate>\n      ) : null}\n\n      {open ? (\n        <PresenceGate key=\"panel-layer\">\n          {({ isPresent, gate }) => (\n            // The layer itself never takes pointer events, so it carries\n            // `inert` alone rather than the gate's pointer-events value.\n            <motion.div\n              layoutRoot\n              layoutScroll\n              data-framer-portal-id=\"morphing-modal\"\n              inert={!isPresent}\n              className={cn(\n                \"pointer-events-none fixed inset-4 z-[80] flex justify-center\",\n                placement === \"bottom\" ? \"items-end pb-4\" : \"items-center\",\n              )}\n            >\n              <motion.div\n                key=\"panel\"\n                layout\n                initial={{ \"--modal-opacity\": 0, y: enterY, scale: enterScale }}\n                animate={{ \"--modal-opacity\": 1, y: 0, scale: 1 }}\n                exit={{\n                  \"--modal-opacity\": 0,\n                  y: enterY,\n                  scale: reduce ? 1 : 0.98,\n                  transition: { duration: 0.18, ease: EASE_OUT },\n                }}\n                transition={SPRING_PANEL}\n                {...gate}\n                className={cn(\n                  \"pointer-events-auto relative w-full max-w-sm overflow-hidden rounded-3xl border border-border bg-background shadow-2xl will-change-transform [opacity:var(--modal-opacity)]\",\n                  className,\n                )}\n              >\n                {/* popLayout offsets must keep the same containing block while\n                    layout projection applies and removes transforms. */}\n                <motion.div layout=\"position\" className=\"relative p-5\">\n                  <AnimatePresence mode=\"popLayout\" initial={false}>\n                    <motion.div\n                      key={viewId}\n                      className={cn(\n                        \"[opacity:var(--modal-opacity)]\",\n                        !reduce && \"[filter:var(--modal-filter)]\",\n                      )}\n                      initial={\n                        reduce\n                          ? { \"--modal-opacity\": 0 }\n                          : { \"--modal-opacity\": 0, y: 8, \"--modal-filter\": \"blur(4px)\" }\n                      }\n                      animate={\n                        reduce\n                          ? {\n                              \"--modal-opacity\": 1,\n                              transition: {\n                                duration: 0.18,\n                                ease: EASE_OUT,\n                              },\n                            }\n                          : {\n                              \"--modal-opacity\": 1,\n                              y: 0,\n                              \"--modal-filter\": \"blur(0px)\",\n                              transition: {\n                                duration: 0.24,\n                                ease: EASE_OUT,\n                              },\n                            }\n                      }\n                      exit={\n                        reduce\n                          ? {\n                              \"--modal-opacity\": 0,\n                              transition: {\n                                duration: 0.14,\n                                ease: EASE_OUT,\n                              },\n                            }\n                          : {\n                              \"--modal-opacity\": 0,\n                              y: -8,\n                              \"--modal-filter\": \"blur(4px)\",\n                              transition: {\n                                duration: 0.16,\n                                ease: EASE_OUT,\n                              },\n                            }\n                      }\n                    >\n                      {children}\n                    </motion.div>\n                  </AnimatePresence>\n                </motion.div>\n              </motion.div>\n            </motion.div>\n          )}\n        </PresenceGate>\n      ) : null}\n    </AnimatePresence>,\n    document.body,\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/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"
    }
  ]
}
