{"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.saura3h.xyz/r/morphing-modal/raw","detail_url":"https://beui.saura3h.xyz/r/morphing-modal","raw_url":"https://beui.saura3h.xyz/r/morphing-modal/raw","page_url":"https://beui.saura3h.xyz/components/motion/morphing-modal","dependencies":["lucide-react","motion","react"],"internal":["@/components/motion/morphing-modal","@/lib/utils"],"files":[{"path":"components/motion/morphing-modal.tsx","type":"component","content":"\"use client\";\n\nimport {\n  AnimatePresence,\n  motion,\n  useReducedMotion,\n  type Transition,\n} from \"motion/react\";\nimport {\n  type ReactNode,\n  useEffect,\n} from \"react\";\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\nconst SPRING: Transition = { type: \"spring\", stiffness: 400, damping: 38, mass: 0.7 };\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  return (\n    <div\n      aria-hidden={!open}\n      className={cn(\n        \"fixed inset-0 z-[80]\",\n        open ? \"pointer-events-auto\" : \"pointer-events-none\",\n      )}\n    >\n      <motion.div\n        initial={false}\n        animate={{ opacity: open ? 1 : 0 }}\n        transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}\n        onClick={onClose}\n        className={cn(\n          \"absolute inset-0 bg-black/50 [backdrop-filter:blur(14px)_saturate(140%)] [-webkit-backdrop-filter:blur(14px)_saturate(140%)]\",\n          open ? \"pointer-events-auto\" : \"pointer-events-none\",\n        )}\n      />\n\n      <div\n        className={cn(\n          \"pointer-events-none absolute inset-0 flex justify-center px-4\",\n          placement === \"bottom\" ? \"items-end pb-8\" : \"items-center\",\n        )}\n      >\n        <AnimatePresence initial={false}>\n          {open ? (\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: [0.16, 1, 0.3, 1] },\n              }}\n              transition={SPRING}\n              className={cn(\n                \"pointer-events-auto relative w-full max-w-sm overflow-hidden rounded-3xl border border-(--color-border-strong) bg-(--color-bg-elev) shadow-[0_30px_60px_-20px_rgb(0_0_0/0.5),0_0_0_1px_rgb(255_255_255/0.04)_inset] 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={reduce ? { opacity: 0 } : { opacity: 0, y: 8, filter: \"blur(4px)\" }}\n                    animate={\n                      reduce\n                        ? { opacity: 1, transition: { duration: 0.18, ease: [0.16, 1, 0.3, 1] } }\n                        : {\n                            opacity: 1,\n                            y: 0,\n                            filter: \"blur(0px)\",\n                            transition: { duration: 0.24, ease: [0.16, 1, 0.3, 1] },\n                          }\n                    }\n                    exit={\n                      reduce\n                        ? { opacity: 0, transition: { duration: 0.14, ease: [0.16, 1, 0.3, 1] } }\n                        : {\n                            opacity: 0,\n                            y: -8,\n                            filter: \"blur(4px)\",\n                            transition: { duration: 0.16, ease: [0.16, 1, 0.3, 1] },\n                          }\n                    }\n                  >\n                    {children}\n                  </motion.div>\n                </AnimatePresence>\n              </motion.div>\n            </motion.div>\n          ) : null}\n        </AnimatePresence>\n      </div>\n    </div>\n  );\n}\n"},{"path":"components/previews/motion/morphing-modal.preview.tsx","type":"preview","content":"\"use client\";\n\nimport { useState } from \"react\";\nimport { Ban, Lock, ScrollText, ShieldCheck, ScanFace, Trash2 } from \"lucide-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-(--color-border) bg-(--color-bg-elev) px-5 text-sm font-medium text-(--color-fg) press hover:border-(--color-border-strong)\"\n      >\n        Open wallet options\n      </button>\n      <p className=\"text-xs text-(--color-fg-muted)\">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-(--color-fg)\">{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-(--color-fg-muted) hover:bg-(--color-fg)/[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-(--color-danger)/10 text-(--color-danger) hover:bg-(--color-danger)/15\"\n          : \"bg-(--color-fg)/[0.04] text-(--color-fg) hover:bg-(--color-fg)/[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-(--color-fg)\" />\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-(--color-fg-muted) hover:bg-(--color-fg)/[0.06]\"\n        >\n          ✕\n        </button>\n      </div>\n      <h2 className=\"text-xl font-semibold tracking-tight text-(--color-fg)\">Private Key</h2>\n      <p className=\"mt-2 text-sm text-(--color-fg-muted)\">\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-(--color-border)\" />\n      <ul className=\"flex flex-col gap-2.5 text-sm text-(--color-fg-muted)\">\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-(--color-fg)/[0.06] text-sm font-medium text-(--color-fg) 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-(--color-fg) text-sm font-medium text-(--color-bg) 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-(--color-fg)\" />\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-(--color-fg-muted) hover:bg-(--color-fg)/[0.06]\"\n        >\n          ✕\n        </button>\n      </div>\n      <h2 className=\"text-xl font-semibold tracking-tight text-(--color-fg)\">Recovery Phrase</h2>\n      <p className=\"mt-2 text-sm text-(--color-fg-muted)\">\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-(--color-border) bg-(--color-bg)/40 px-2 py-1.5 text-xs text-(--color-fg)\">\n            <span className=\"mr-1 text-(--color-fg-muted)\">{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-(--color-fg) text-sm font-medium text-(--color-bg) press\"\n      >\n        Done\n      </button>\n    </div>\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"}]}