{"slug":"morphing-modal","name":"Morphing Modal","description":"Family-app-style modal. A single panel that morphs its height as you navigate between inner views, with blur cross-fade on content.","category":"motion","source_url":"https://beui.dev/r/morphing-modal/raw","detail_url":"https://beui.dev/r/morphing-modal","raw_url":"https://beui.dev/r/morphing-modal/raw","page_url":"https://beui.dev/components/motion/morphing-modal","markdown_url":"https://beui.dev/components/motion/morphing-modal.md","published_at":"2026-05-17","updated_at":"2026-08-20","dependencies":["clsx","lucide-react","motion","react","tailwind-merge"],"internal":["@/components/motion/morphing-modal","@/lib/ease","@/lib/presence-gate","@/lib/utils"],"files":[{"path":"components/motion/morphing-modal.tsx","type":"component","content":"\"use client\";\n// beui.dev/components/motion/morphing-modal\n\nimport {\n  AnimatePresence,\n  motion,\n  useReducedMotion,\n} from \"motion/react\";\nimport { type ReactNode, useEffect } from \"react\";\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\n  useEffect(() => {\n    if (!open) return;\n    const prev = document.body.style.overflow;\n    document.body.style.overflow = \"hidden\";\n    return () => {\n      document.body.style.overflow = prev;\n    };\n  }, [open]);\n\n  // Mounted only while open, and while open the chrome is two fixed siblings\n  // rather than one wrapper: the backdrop spans the viewport edges but carries\n  // the scrim colour, and the layer positioning the panel sits inset off every\n  // edge (`inset-4`, with the bottom placement's `pb-4` on top of it). Both hang\n  // off `PresenceGate`, so interaction releases in the same commit that starts\n  // the exit rather than when it ends — `open` is already false for those\n  // frames.\n  return (\n    <AnimatePresence initial={false}>\n      {open ? (\n        <PresenceGate key=\"backdrop\">\n          {({ gate }) => (\n            <motion.button\n              type=\"button\"\n              aria-label=\"Close modal\"\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1 }}\n              exit={{ 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%)]\"\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            <div\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={{ opacity: 0, y: enterY, scale: enterScale }}\n                animate={{ opacity: 1, y: 0, scale: 1 }}\n                exit={{\n                  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\",\n                  className,\n                )}\n              >\n                <motion.div layout=\"position\" className=\"p-5\">\n                  <AnimatePresence mode=\"popLayout\" initial={false}>\n                    <motion.div\n                      key={viewId}\n                      initial={\n                        reduce\n                          ? { opacity: 0 }\n                          : { opacity: 0, y: 8, filter: \"blur(4px)\" }\n                      }\n                      animate={\n                        reduce\n                          ? {\n                              opacity: 1,\n                              transition: {\n                                duration: 0.18,\n                                ease: EASE_OUT,\n                              },\n                            }\n                          : {\n                              opacity: 1,\n                              y: 0,\n                              filter: \"blur(0px)\",\n                              transition: {\n                                duration: 0.24,\n                                ease: EASE_OUT,\n                              },\n                            }\n                      }\n                      exit={\n                        reduce\n                          ? {\n                              opacity: 0,\n                              transition: {\n                                duration: 0.14,\n                                ease: EASE_OUT,\n                              },\n                            }\n                          : {\n                              opacity: 0,\n                              y: -8,\n                              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            </div>\n          )}\n        </PresenceGate>\n      ) : null}\n    </AnimatePresence>\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/morphing-modal.preview.tsx","type":"preview","content":"\"use client\";\n\nimport { Ban, Lock, ScanFace, ScrollText, ShieldCheck, Trash2 } from \"lucide-react\";\nimport { useState } from \"react\";\nimport { MorphingModal } from \"@/components/motion/morphing-modal\";\n\ntype View = \"options\" | \"private-key\" | \"recovery\" | null;\n\nexport function MorphingModalPreview() {\n  const [view, setView] = useState<View>(null);\n\n  return (\n    <div className=\"flex flex-col items-center gap-3\">\n      <button\n        type=\"button\"\n        onClick={() => setView(\"options\")}\n        className=\"inline-flex h-10 items-center rounded-full border border-border bg-card px-5 text-sm font-medium text-foreground press hover:border-(--color-border-strong)\"\n      >\n        Open wallet options\n      </button>\n      <p className=\"text-xs text-muted-foreground\">Click a row. The modal morphs height to match new content.</p>\n\n      <MorphingModal viewId={view} onClose={() => setView(null)}>\n        {view === \"options\" ? (\n          <Options\n            onPrivateKey={() => setView(\"private-key\")}\n            onRecovery={() => setView(\"recovery\")}\n            onClose={() => setView(null)}\n          />\n        ) : view === \"private-key\" ? (\n          <PrivateKey onBack={() => setView(\"options\")} />\n        ) : view === \"recovery\" ? (\n          <Recovery onBack={() => setView(\"options\")} />\n        ) : null}\n      </MorphingModal>\n    </div>\n  );\n}\n\nfunction Header({ title, onClose }: { title: string; onClose: () => void }) {\n  return (\n    <div className=\"mb-4 flex items-center justify-between\">\n      <h2 className=\"text-base font-semibold text-foreground\">{title}</h2>\n      <button\n        type=\"button\"\n        onClick={onClose}\n        aria-label=\"Close\"\n        className=\"inline-flex h-7 w-7 items-center justify-center rounded-full text-muted-foreground hover:bg-foreground/[0.06]\"\n      >\n        ✕\n      </button>\n    </div>\n  );\n}\n\nfunction Row({\n  icon: Icon,\n  label,\n  destructive,\n  onClick,\n}: {\n  icon: typeof Lock;\n  label: string;\n  destructive?: boolean;\n  onClick: () => void;\n}) {\n  return (\n    <button\n      type=\"button\"\n      onClick={onClick}\n      className={`flex w-full items-center gap-3 rounded-2xl px-4 py-3 text-sm font-medium transition-colors press ${\n        destructive\n          ? \"bg-destructive/10 text-destructive hover:bg-destructive/15\"\n          : \"bg-foreground/[0.04] text-foreground hover:bg-foreground/[0.08]\"\n      }`}\n    >\n      <Icon className=\"h-4 w-4\" />\n      {label}\n    </button>\n  );\n}\n\nfunction Options({\n  onPrivateKey,\n  onRecovery,\n  onClose,\n}: {\n  onPrivateKey: () => void;\n  onRecovery: () => void;\n  onClose: () => void;\n}) {\n  return (\n    <div>\n      <Header title=\"Options\" onClose={onClose} />\n      <div className=\"flex flex-col gap-2\">\n        <Row icon={Lock} label=\"View Private Key\" onClick={onPrivateKey} />\n        <Row icon={ScrollText} label=\"View Recovery Phrase\" onClick={onRecovery} />\n        <Row icon={Trash2} label=\"Remove Wallet\" destructive onClick={onClose} />\n      </div>\n    </div>\n  );\n}\n\nfunction PrivateKey({ onBack }: { onBack: () => void }) {\n  return (\n    <div>\n      <div className=\"mb-3 flex items-start justify-between\">\n        <Lock className=\"h-5 w-5 text-foreground\" />\n        <button\n          type=\"button\"\n          onClick={onBack}\n          aria-label=\"Back\"\n          className=\"inline-flex h-7 w-7 items-center justify-center rounded-full text-muted-foreground hover:bg-foreground/[0.06]\"\n        >\n          ✕\n        </button>\n      </div>\n      <h2 className=\"text-xl font-semibold tracking-tight text-foreground\">Private Key</h2>\n      <p className=\"mt-2 text-sm text-muted-foreground\">\n        Your Private Key is the key used to back up your wallet. Keep it secret and secure at all times.\n      </p>\n      <hr className=\"my-4 border-border\" />\n      <ul className=\"flex flex-col gap-2.5 text-sm text-muted-foreground\">\n        <li className=\"flex items-center gap-2.5\"><ShieldCheck className=\"h-4 w-4\" /> Keep your private key safe</li>\n        <li className=\"flex items-center gap-2.5\"><ScrollText className=\"h-4 w-4\" /> Don&apos;t share it with anyone else</li>\n        <li className=\"flex items-center gap-2.5\"><Ban className=\"h-4 w-4\" /> If you lose it, we can&apos;t recover it</li>\n      </ul>\n      <div className=\"mt-5 flex gap-2\">\n        <button\n          type=\"button\"\n          onClick={onBack}\n          className=\"inline-flex h-10 flex-1 items-center justify-center rounded-full bg-foreground/[0.06] text-sm font-medium text-foreground press\"\n        >\n          Cancel\n        </button>\n        <button\n          type=\"button\"\n          onClick={onBack}\n          className=\"inline-flex h-10 flex-1 items-center justify-center gap-2 rounded-full bg-foreground text-sm font-medium text-background press\"\n        >\n          <ScanFace className=\"h-4 w-4\" />\n          Reveal\n        </button>\n      </div>\n    </div>\n  );\n}\n\nfunction Recovery({ onBack }: { onBack: () => void }) {\n  return (\n    <div>\n      <div className=\"mb-3 flex items-start justify-between\">\n        <ScrollText className=\"h-5 w-5 text-foreground\" />\n        <button\n          type=\"button\"\n          onClick={onBack}\n          aria-label=\"Back\"\n          className=\"inline-flex h-7 w-7 items-center justify-center rounded-full text-muted-foreground hover:bg-foreground/[0.06]\"\n        >\n          ✕\n        </button>\n      </div>\n      <h2 className=\"text-xl font-semibold tracking-tight text-foreground\">Recovery Phrase</h2>\n      <p className=\"mt-2 text-sm text-muted-foreground\">\n        12 words you can use to restore your wallet on any device. Write them down somewhere safe.\n      </p>\n      <div className=\"mt-4 grid grid-cols-3 gap-2\">\n        {[\"mountain\", \"river\", \"candle\", \"harbor\", \"amber\", \"violet\", \"spring\", \"ocean\", \"marble\", \"thunder\", \"willow\", \"crystal\"].map((w, i) => (\n          <div key={w} className=\"rounded-lg border border-border bg-background/40 px-2 py-1.5 text-xs text-foreground\">\n            <span className=\"mr-1 text-muted-foreground\">{i + 1}.</span>\n            {w}\n          </div>\n        ))}\n      </div>\n      <button\n        type=\"button\"\n        onClick={onBack}\n        className=\"mt-5 inline-flex h-10 w-full items-center justify-center rounded-full bg-foreground text-sm font-medium text-background press\"\n      >\n        Done\n      </button>\n    </div>\n  );\n}\n"}]}