{"slug":"center-morph-modal","name":"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.","category":"motion","source_url":"https://beui.dev/r/center-morph-modal/raw","detail_url":"https://beui.dev/r/center-morph-modal","raw_url":"https://beui.dev/r/center-morph-modal/raw","page_url":"https://beui.dev/components/motion/center-morph-modal","markdown_url":"https://beui.dev/components/motion/center-morph-modal.md","published_at":"2026-07-21","updated_at":"2026-08-20","dependencies":["clsx","lucide-react","motion","react","react-dom","tailwind-merge"],"internal":["@/components/motion/center-morph-modal","@/lib/ease","@/lib/presence-gate","@/lib/utils"],"files":[{"path":"components/motion/center-morph-modal.tsx","type":"component","content":"\"use client\";\n// beui.dev/components/motion/center-morph-modal\n\nimport { X } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } 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 { PresenceGate } from \"@/lib/presence-gate\";\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\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 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(panelRef.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(panelRef.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        <PresenceGate>\n          {({ isPresent, gate }) => (\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                {...gate}\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 fixed inset-0 z-[100] h-full w-full cursor-default bg-background/10 backdrop-blur-sm\",\n                  backdropClassName,\n                )}\n              />\n\n              {/* `inset-4` rather than `inset-0 p-4`: same content box, but the\n                  layer stays off the viewport edges. It never takes pointer\n                  events, so it carries `inert` alone. */}\n              <div\n                inert={!isPresent}\n                className=\"pointer-events-none fixed inset-4 z-[100] flex items-center justify-center overflow-y-auto drop-shadow-2xl\"\n              >\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                    {...gate}\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            </>\n          )}\n        </PresenceGate>\n      ) : null}\n    </AnimatePresence>,\n    document.body,\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/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"},{"path":"components/previews/motion/center-morph-modal.preview.tsx","type":"preview","content":"\"use client\";\n\nimport { ArrowUpRight, Check } from \"lucide-react\";\nimport {\n  CenterMorphModal,\n  CenterMorphModalContent,\n  CenterMorphModalTrigger,\n} from \"@/components/motion/center-morph-modal\";\n\nexport function CenterMorphModalPreview() {\n  return (\n    <div className=\"flex min-h-[420px] w-full items-center justify-center\">\n      <CenterMorphModal>\n        <CenterMorphModalTrigger>\n          <button\n            type=\"button\"\n            className=\"inline-flex h-10 items-center justify-center rounded-full bg-foreground px-5 text-sm font-medium text-background press focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n          >\n            Open modal\n          </button>\n        </CenterMorphModalTrigger>\n\n        <CenterMorphModalContent\n          ariaLabel=\"beUI Pro\"\n          ariaDescribedBy=\"center-morph-pro-description\"\n        >\n          <div className=\"p-7 sm:p-8\">\n            <p className=\"text-sm font-medium text-muted-foreground\">\n              beUI Pro\n            </p>\n            <h2 className=\"mt-5 max-w-xs pr-8 text-2xl font-medium tracking-tight text-foreground\">\n              Ship the whole experience.\n            </h2>\n            <p\n              id=\"center-morph-pro-description\"\n              className=\"mt-3 text-sm leading-relaxed text-muted-foreground\"\n            >\n              Go beyond individual components with premium animated sections\n              and complete Next.js templates.\n            </p>\n\n            <div className=\"mt-7 space-y-3 border-y border-border py-5\">\n              {[\n                \"Premium animated sections\",\n                \"Complete Next.js templates\",\n                \"Editable source and private registry\",\n              ].map((feature) => (\n                <div\n                  key={feature}\n                  className=\"flex items-center gap-3 text-sm text-foreground\"\n                >\n                  <Check\n                    className=\"h-4 w-4 text-muted-foreground\"\n                    aria-hidden=\"true\"\n                  />\n                  <span>{feature}</span>\n                </div>\n              ))}\n            </div>\n\n            <a\n              href=\"https://pro.beui.dev/?utm_source=beui&utm_medium=component_preview&utm_campaign=center_morph_modal\"\n              target=\"_blank\"\n              rel=\"noreferrer\"\n              className=\"mt-7 inline-flex h-11 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-sm font-medium text-background press focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n            >\n              Explore beUI Pro\n              <ArrowUpRight className=\"h-4 w-4\" aria-hidden=\"true\" />\n            </a>\n          </div>\n        </CenterMorphModalContent>\n      </CenterMorphModal>\n    </div>\n  );\n}\n"}]}