{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"center-morph-modal","type":"registry:component","title":"Center Morph Modal","description":"A composable modal whose full-size surface unfolds from its exact center toward every edge, then folds back the same way with an inset close control.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/center-morph-modal.tsx","type":"registry:component","target":"@components/motion/center-morph-modal.tsx","content":"\"use client\";\n// beui.dev/components/motion/center-morph-modal\n\nimport { X } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  motion,\n  useIsPresent,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  cloneElement,\n  createContext,\n  isValidElement,\n  type ReactElement,\n  type ReactNode,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\ntype CenterMorphModalContextValue = {\n  open: boolean;\n  setOpen: (open: boolean) => void;\n  triggerId: string;\n  contentId: string;\n};\n\nconst CenterMorphModalContext =\n  createContext<CenterMorphModalContextValue | null>(null);\n\nfunction useCenterMorphModalContext(component: string) {\n  const context = useContext(CenterMorphModalContext);\n  if (!context) {\n    throw new Error(`${component} must be used within <CenterMorphModal>`);\n  }\n  return context;\n}\n\nexport interface CenterMorphModalProps {\n  children: ReactNode;\n  /** Controlled open state. */\n  open?: boolean;\n  /** Initial state when used uncontrolled. */\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n}\n\n/**\n * A modal whose full-size surface unfolds outward from its exact center.\n * Supports controlled and uncontrolled state through composable primitives.\n */\nexport function CenterMorphModal({\n  children,\n  open: controlledOpen,\n  defaultOpen = false,\n  onOpenChange,\n}: CenterMorphModalProps) {\n  const id = useId();\n  const [internalOpen, setInternalOpen] = useState(defaultOpen);\n  const controlled = controlledOpen !== undefined;\n  const open = controlled ? controlledOpen : internalOpen;\n\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (!controlled) setInternalOpen(next);\n      onOpenChange?.(next);\n    },\n    [controlled, onOpenChange],\n  );\n\n  const value = useMemo<CenterMorphModalContextValue>(\n    () => ({\n      open,\n      setOpen,\n      triggerId: `${id}-trigger`,\n      contentId: `${id}-content`,\n    }),\n    [id, open, setOpen],\n  );\n\n  return (\n    <CenterMorphModalContext.Provider value={value}>\n      {children}\n    </CenterMorphModalContext.Provider>\n  );\n}\n\nexport interface CenterMorphModalTriggerProps {\n  children: ReactElement;\n}\n\n/** Wraps one interactive element and opens or closes the modal. */\nexport function CenterMorphModalTrigger({\n  children,\n}: CenterMorphModalTriggerProps) {\n  const context = useCenterMorphModalContext(\"CenterMorphModalTrigger\");\n  if (!isValidElement(children)) return children;\n\n  const child = children as ReactElement<Record<string, unknown>>;\n  const childOnClick = child.props.onClick as\n    | ((event: React.MouseEvent<HTMLElement>) => void)\n    | undefined;\n\n  return cloneElement(child, {\n    id: context.triggerId,\n    onClick: (event: React.MouseEvent<HTMLElement>) => {\n      childOnClick?.(event);\n      if (!event.defaultPrevented) context.setOpen(!context.open);\n    },\n    \"aria-haspopup\": \"dialog\",\n    \"aria-expanded\": context.open,\n    \"aria-controls\": context.open ? context.contentId : undefined,\n  });\n}\n\nexport interface CenterMorphModalCloseProps {\n  children: ReactElement;\n}\n\n/** Wraps one interactive element and closes the modal. */\nexport function CenterMorphModalClose({\n  children,\n}: CenterMorphModalCloseProps) {\n  const context = useCenterMorphModalContext(\"CenterMorphModalClose\");\n  if (!isValidElement(children)) return children;\n\n  const child = children as ReactElement<Record<string, unknown>>;\n  const childOnClick = child.props.onClick as\n    | ((event: React.MouseEvent<HTMLElement>) => void)\n    | undefined;\n\n  return cloneElement(child, {\n    onClick: (event: React.MouseEvent<HTMLElement>) => {\n      childOnClick?.(event);\n      if (!event.defaultPrevented) context.setOpen(false);\n    },\n  });\n}\n\nexport interface CenterMorphModalContentProps {\n  children: ReactNode;\n  /** Accessible name announced by screen readers. */\n  ariaLabel: string;\n  /** Optional id of descriptive content inside the modal. */\n  ariaDescribedBy?: string;\n  /** Close on Escape or backdrop press. Default true. */\n  dismissible?: boolean;\n  /** Render the close control inside the panel's top-right corner. Default true. */\n  showCloseButton?: boolean;\n  closeButtonLabel?: string;\n  className?: string;\n  backdropClassName?: string;\n}\n\nconst FOCUSABLE_SELECTOR = [\n  \"a[href]\",\n  \"button:not([disabled])\",\n  \"input:not([disabled])\",\n  \"select:not([disabled])\",\n  \"textarea:not([disabled])\",\n  '[tabindex]:not([tabindex=\"-1\"])',\n].join(\",\");\n\nconst CENTER_FOLDED_CLIP =\n  \"inset(48% 48% 48% 48% round 30px)\";\nconst CENTER_OPEN_CLIP = \"inset(0% 0% 0% 0% round 30px)\";\n\n// Complex clip-path strings can snap when a spring resolves its final distance.\n// Keep the radius constant so the whole duration reads as surface unfolding,\n// rather than finishing early and spending its last frames rounding corners.\nconst CENTER_UNFOLD_EASE = [0.2, 0, 0.2, 1] as const;\nconst CENTER_UNFOLD_TRANSITION = {\n  duration: 0.43,\n  ease: CENTER_UNFOLD_EASE,\n} as const;\n\nfunction getFocusableElements(root: HTMLElement | null) {\n  if (!root) return [];\n  return Array.from(\n    root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),\n  ).filter((element) => element.tabIndex >= 0);\n}\n\nfunction PresencePointerGate({\n  children,\n}: {\n  children: (isPresent: boolean) => ReactNode;\n}) {\n  return children(useIsPresent());\n}\n\nexport function CenterMorphModalContent({\n  children,\n  ariaLabel,\n  ariaDescribedBy,\n  dismissible = true,\n  showCloseButton = true,\n  closeButtonLabel = \"Close modal\",\n  className,\n  backdropClassName,\n}: CenterMorphModalContentProps) {\n  const context = useCenterMorphModalContext(\"CenterMorphModalContent\");\n  const reduce = useReducedMotion() ?? false;\n  const [mounted, setMounted] = useState(false);\n  const overlayRef = useRef<HTMLDivElement>(null);\n  const panelRef = useRef<HTMLDivElement>(null);\n\n  useEffect(() => setMounted(true), []);\n\n  useEffect(() => {\n    if (!context.open) return;\n\n    const previousOverflow = document.body.style.overflow;\n    document.body.style.overflow = \"hidden\";\n\n    const focusFrame = requestAnimationFrame(() => {\n      const [firstFocusable] = getFocusableElements(overlayRef.current);\n      (firstFocusable ?? panelRef.current)?.focus();\n    });\n\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\" && dismissible) {\n        event.preventDefault();\n        context.setOpen(false);\n        return;\n      }\n\n      if (event.key !== \"Tab\") return;\n      const focusable = getFocusableElements(overlayRef.current);\n      if (focusable.length === 0) {\n        event.preventDefault();\n        panelRef.current?.focus();\n        return;\n      }\n\n      const first = focusable[0];\n      const last = focusable[focusable.length - 1];\n      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\n    window.addEventListener(\"keydown\", onKeyDown);\n    return () => {\n      cancelAnimationFrame(focusFrame);\n      window.removeEventListener(\"keydown\", onKeyDown);\n      document.body.style.overflow = previousOverflow;\n      document.getElementById(context.triggerId)?.focus();\n    };\n  }, [context, dismissible]);\n\n  if (!mounted) return null;\n\n  return createPortal(\n    <AnimatePresence>\n      {context.open ? (\n        <PresencePointerGate>\n          {(isPresent) => (\n            <div\n              ref={overlayRef}\n              className=\"pointer-events-none fixed inset-0 z-[100]\"\n            >\n          <motion.button\n            type=\"button\"\n            aria-label=\"Dismiss modal\"\n            tabIndex={-1}\n            disabled={!dismissible}\n            initial={{ opacity: 0 }}\n            animate={{ opacity: 1 }}\n            exit={{ opacity: 0 }}\n            style={{ pointerEvents: isPresent ? \"auto\" : \"none\" }}\n            transition={{\n              duration: reduce ? 0.1 : 0.28,\n              ease: EASE_OUT,\n            }}\n            onClick={() => context.setOpen(false)}\n            className={cn(\n              \"pointer-events-auto absolute inset-0 h-full w-full cursor-default bg-background/10 backdrop-blur-sm\",\n              backdropClassName,\n            )}\n          />\n\n          <div className=\"pointer-events-none absolute inset-0 flex items-center justify-center overflow-y-auto p-4 drop-shadow-2xl\">\n            {/* Drop-shadow reads the clipped child's alpha, so depth follows the\n                unfolding silhouette without introducing another panel layer. */}\n            <div className=\"flex w-full flex-col items-center py-8\">\n              <motion.div\n                ref={panelRef}\n                id={context.contentId}\n                role=\"dialog\"\n                aria-modal=\"true\"\n                aria-label={ariaLabel}\n                aria-describedby={ariaDescribedBy}\n                tabIndex={-1}\n                initial={\n                  reduce\n                    ? { opacity: 0, clipPath: CENTER_OPEN_CLIP }\n                    : { opacity: 1, clipPath: CENTER_FOLDED_CLIP }\n                }\n                animate={{\n                  opacity: 1,\n                  clipPath: CENTER_OPEN_CLIP,\n                }}\n                exit={\n                  reduce\n                    ? {\n                        opacity: 0,\n                        clipPath: CENTER_OPEN_CLIP,\n                      }\n                    : {\n                        opacity: 1,\n                        clipPath: CENTER_FOLDED_CLIP,\n                      }\n                }\n                style={{ pointerEvents: isPresent ? \"auto\" : \"none\" }}\n                transition={\n                  reduce\n                    ? { duration: 0.14, ease: EASE_OUT }\n                    : CENTER_UNFOLD_TRANSITION\n                }\n                className={cn(\n                  \"pointer-events-auto relative w-full max-w-[26rem] origin-center overflow-hidden rounded-[30px] border border-border bg-background will-change-[clip-path]\",\n                  className,\n                )}\n              >\n                {children}\n\n                {showCloseButton ? (\n                  <motion.button\n                    type=\"button\"\n                    aria-label={closeButtonLabel}\n                    onClick={() => context.setOpen(false)}\n                    initial={\n                      reduce\n                        ? { opacity: 0 }\n                        : { opacity: 0, scale: 0.8 }\n                    }\n                    animate={{ opacity: 1, scale: 1 }}\n                    exit={{\n                      opacity: 0,\n                      scale: reduce ? 1 : 0.88,\n                      transition: { duration: 0.1, ease: EASE_OUT },\n                    }}\n                    transition={{\n                      delay: reduce ? 0 : 0.16,\n                      duration: reduce ? 0.12 : 0.2,\n                      ease: EASE_OUT,\n                    }}\n                    className=\"absolute right-4 top-4 inline-flex h-8 w-8 items-center justify-center rounded-full bg-foreground/[0.05] text-muted-foreground transition-colors hover:bg-foreground/[0.08] hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n                  >\n                    <X className=\"h-4 w-4\" aria-hidden=\"true\" />\n                  </motion.button>\n                ) : null}\n              </motion.div>\n            </div>\n          </div>\n            </div>\n          )}\n        </PresencePointerGate>\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"},{"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"}]}