{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"morphing-modal","type":"registry:ui","title":"Origin Modal","description":"A multi-view modal that opens from the button that launched it and returns there on close; the panel height springs between views and content crossfades through a blur.","author":"GetUrUI <hello@geturui.dev>","dependencies":["motion","lucide-react"],"registryDependencies":[],"files":[{"path":"registry/geturui/ui/morphing-modal.tsx","type":"registry:ui","target":"components/ui/morphing-modal.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { ChevronLeft, X } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface MorphingModalNav {\n  /** Id of the view on screen. */\n  view: string;\n  /** Push a view onto the stack (slides in from the right). */\n  go: (id: string) => void;\n  /** Pop back to the previous view. */\n  back: () => void;\n  close: () => void;\n  canGoBack: boolean;\n}\n\nexport interface MorphingModalView {\n  id: string;\n  title: React.ReactNode;\n  description?: React.ReactNode;\n  /** Static content, or a render function that receives the navigator. */\n  content: React.ReactNode | ((nav: MorphingModalNav) => React.ReactNode);\n}\n\nexport interface MorphingModalProps {\n  /** Content of the trigger button; the button itself morphs into the panel. */\n  trigger: React.ReactNode;\n  views: MorphingModalView[];\n  /** View shown each time the modal opens. Defaults to the first view. */\n  initialView?: string;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Panel width in px (clamped to the viewport). */\n  width?: number;\n  triggerClassName?: string;\n  /** Classes for the panel. */\n  className?: string;\n}\n\nconst NavContext = React.createContext<MorphingModalNav | null>(null);\n\n/** Navigate between views from any component rendered inside a MorphingModal view. */\nexport function useMorphingModal() {\n  const ctx = React.useContext(NavContext);\n  if (!ctx) throw new Error(\"useMorphingModal must be used inside <MorphingModal> content\");\n  return ctx;\n}\n\nconst FOCUSABLE =\n  'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex=\"-1\"])';\n\nconst SHELL_SPRING = { type: \"spring\", stiffness: 340, damping: 32 } as const;\nconst HEIGHT_SPRING = { type: \"spring\", stiffness: 320, damping: 32 } as const;\n\nconst noop = () => () => {};\n\nconst viewVariants = {\n  enter: ({ dir, reduce }: { dir: number; reduce: boolean }) => ({ opacity: 0, x: reduce ? 0 : 28 * dir, filter: reduce ? \"blur(0px)\" : \"blur(8px)\" }),\n  center: { opacity: 1, x: 0, filter: \"blur(0px)\" },\n  exit: ({ dir, reduce }: { dir: number; reduce: boolean }) => ({ opacity: 0, x: reduce ? 0 : -28 * dir, filter: reduce ? \"blur(0px)\" : \"blur(8px)\" }),\n};\n\nconst titleVariants = {\n  enter: ({ dir, reduce }: { dir: number; reduce: boolean }) => ({ opacity: 0, y: reduce ? 0 : 6 * dir, filter: reduce ? \"blur(0px)\" : \"blur(6px)\" }),\n  center: { opacity: 1, y: 0, filter: \"blur(0px)\" },\n  exit: ({ dir, reduce }: { dir: number; reduce: boolean }) => ({ opacity: 0, y: reduce ? 0 : -6 * dir, filter: reduce ? \"blur(0px)\" : \"blur(6px)\" }),\n};\n\nconst triggerBase =\n  \"inline-flex h-10 items-center justify-center gap-2 whitespace-nowrap px-[18px] text-sm font-medium tracking-[-0.1px]\";\n\n/** A trigger button that morphs into a multi-view modal; height springs between views while content crossfades with a blur. */\nexport function MorphingModal({\n  trigger,\n  views,\n  initialView,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  width = 420,\n  triggerClassName,\n  className,\n}: MorphingModalProps) {\n  const uid = React.useId();\n  const reduce = useReducedMotion();\n  const [inner, setInner] = React.useState(defaultOpen);\n  const [touched, setTouched] = React.useState(defaultOpen);\n  const [lifted, setLifted] = React.useState(false);\n  const open = openProp ?? inner;\n  const triggerRef = React.useRef<HTMLButtonElement>(null);\n  const wasOpen = React.useRef(open);\n  const mounted = React.useSyncExternalStore(noop, () => true, () => false);\n\n  const setOpen = React.useCallback(\n    (next: boolean) => {\n      if (openProp === undefined) setInner(next);\n      setTouched(true);\n      if (!next) setLifted(true);\n      onOpenChange?.(next);\n    },\n    [openProp, onOpenChange],\n  );\n\n  // Return focus to the trigger once it re-mounts after closing.\n  React.useEffect(() => {\n    if (wasOpen.current && !open) {\n      const raf = requestAnimationFrame(() => triggerRef.current?.focus({ preventScroll: true }));\n      wasOpen.current = open;\n      return () => cancelAnimationFrame(raf);\n    }\n    wasOpen.current = open;\n  }, [open]);\n\n  const shellId = reduce ? undefined : `${uid}-shell`;\n\n  return (\n    <>\n      <span className=\"relative inline-flex\">\n        {/* Invisible copy holds the trigger's footprint while it is morphed away. */}\n        <span aria-hidden className={cn(triggerBase, \"invisible rounded-full border border-transparent\", triggerClassName)}>\n          {trigger}\n        </span>\n        {!open && (\n          <motion.button\n            ref={triggerRef}\n            type=\"button\"\n            layoutId={shellId}\n            aria-haspopup=\"dialog\"\n            aria-expanded={false}\n            onClick={() => setOpen(true)}\n            onLayoutAnimationComplete={() => setLifted(false)}\n            transition={SHELL_SPRING}\n            style={{ borderRadius: 999 }}\n            className={cn(\n              triggerBase,\n              \"absolute inset-0 border border-border bg-surface text-ink outline-none transition-colors duration-150 hover:border-border-strong hover:bg-surface-raised focus-visible:shadow-[var(--focus-ring)]\",\n              lifted && \"z-[95]\",\n              triggerClassName,\n            )}\n          >\n            <motion.span\n              initial={touched ? { opacity: 0 } : false}\n              animate={{ opacity: 1 }}\n              transition={{ duration: 0.2, delay: 0.12 }}\n              className=\"inline-flex items-center gap-[inherit]\"\n            >\n              {trigger}\n            </motion.span>\n          </motion.button>\n        )}\n      </span>\n      {mounted &&\n        createPortal(\n          <AnimatePresence>\n            {open && (\n              <ModalPanel\n                key=\"panel\"\n                shellId={shellId}\n                views={views}\n                initialView={initialView ?? views[0]?.id}\n                width={width}\n                reduce={!!reduce}\n                className={className}\n                onClose={() => setOpen(false)}\n              />\n            )}\n          </AnimatePresence>,\n          document.body,\n        )}\n    </>\n  );\n}\n\ninterface ModalPanelProps {\n  shellId?: string;\n  views: MorphingModalView[];\n  initialView: string;\n  width: number;\n  reduce: boolean;\n  className?: string;\n  onClose: () => void;\n}\n\nfunction ModalPanel({ shellId, views, initialView, width, reduce, className, onClose }: ModalPanelProps) {\n  const titleId = React.useId();\n  const descId = React.useId();\n  const [stack, setStack] = React.useState<string[]>([initialView]);\n  const [dir, setDir] = React.useState(1);\n  const [height, setHeight] = React.useState<number | \"auto\">(\"auto\");\n  const panelRef = React.useRef<HTMLDivElement>(null);\n  const headingRef = React.useRef<HTMLHeadingElement>(null);\n  const bodyRef = React.useRef<HTMLDivElement>(null);\n  const closeRef = React.useRef(onClose);\n  React.useEffect(() => {\n    closeRef.current = onClose;\n  }, [onClose]);\n\n  const current = stack[stack.length - 1];\n  const view = views.find((v) => v.id === current) ?? views[0];\n\n  const nav = React.useMemo<MorphingModalNav>(\n    () => ({\n      view: current,\n      canGoBack: stack.length > 1,\n      go: (id) => {\n        setDir(1);\n        setStack((s) => (s[s.length - 1] === id ? s : [...s, id]));\n      },\n      back: () => {\n        setDir(-1);\n        setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n      },\n      close: onClose,\n    }),\n    [current, stack.length, onClose],\n  );\n\n  // Measure the active view so the panel height can spring to it.\n  React.useEffect(() => {\n    const el = bodyRef.current;\n    if (!el) return;\n    const ro = new ResizeObserver(() => setHeight(el.offsetHeight));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n\n  // Escape closes, Tab stays inside, body scroll is locked while open.\n  React.useEffect(() => {\n    const prevOverflow = document.body.style.overflow;\n    const prevPadding = document.body.style.paddingRight;\n    const gap = window.innerWidth - document.documentElement.clientWidth;\n    document.body.style.overflow = \"hidden\";\n    if (gap > 0) document.body.style.paddingRight = `${gap}px`;\n    function onKey(e: KeyboardEvent) {\n      if (e.key === \"Escape\") {\n        e.preventDefault();\n        closeRef.current();\n      }\n    }\n    document.addEventListener(\"keydown\", onKey);\n    return () => {\n      document.removeEventListener(\"keydown\", onKey);\n      document.body.style.overflow = prevOverflow;\n      document.body.style.paddingRight = prevPadding;\n    };\n  }, []);\n\n  // Move focus to the heading on open and on every view change.\n  React.useEffect(() => {\n    const raf = requestAnimationFrame(() => headingRef.current?.focus({ preventScroll: true }));\n    return () => cancelAnimationFrame(raf);\n  }, [current]);\n\n  function trapTab(e: React.KeyboardEvent) {\n    if (e.key !== \"Tab\" || !panelRef.current) return;\n    const nodes = Array.from(panelRef.current.querySelectorAll<HTMLElement>(FOCUSABLE)).filter((n) => !n.closest(\"[aria-hidden=true]\"));\n    if (!nodes.length) return;\n    const first = nodes[0];\n    const last = nodes[nodes.length - 1];\n    if (e.shiftKey && (document.activeElement === first || document.activeElement === headingRef.current)) {\n      e.preventDefault();\n      last.focus();\n    } else if (!e.shiftKey && document.activeElement === last) {\n      e.preventDefault();\n      first.focus();\n    }\n  }\n\n  const custom = { dir, reduce };\n\n  return (\n    <div className=\"fixed inset-0 z-[90] grid place-items-center p-4\">\n      <motion.div\n        aria-hidden\n        className=\"absolute inset-0 bg-scrim backdrop-blur-[3px]\"\n        initial={{ opacity: 0 }}\n        animate={{ opacity: 1 }}\n        exit={{ opacity: 0 }}\n        transition={{ duration: 0.22 }}\n        onClick={() => closeRef.current()}\n      />\n      <motion.div\n        ref={panelRef}\n        layoutId={shellId}\n        layoutDependency={shellId}\n        role=\"dialog\"\n        aria-modal=\"true\"\n        aria-labelledby={titleId}\n        aria-describedby={view?.description ? descId : undefined}\n        onKeyDown={trapTab}\n        initial={shellId ? undefined : { opacity: 0, scale: 0.97 }}\n        animate={shellId ? undefined : { opacity: 1, scale: 1 }}\n        exit={shellId ? undefined : { opacity: 0, scale: 0.97 }}\n        transition={SHELL_SPRING}\n        style={{ borderRadius: 20, width: `min(${width}px, calc(100vw - 32px))` }}\n        className={cn(\"relative overflow-hidden border border-border bg-surface-raised text-ink shadow-lg\", className)}\n      >\n        <motion.div\n          initial={{ opacity: 0 }}\n          animate={{ opacity: 1, transition: { duration: 0.2, delay: shellId ? 0.12 : 0 } }}\n          exit={{ opacity: 0, transition: { duration: 0.08 } }}\n        >\n          <div className=\"flex items-center gap-2 px-4 pb-1 pt-4\">\n            <AnimatePresence initial={false}>\n              {nav.canGoBack && (\n                <motion.button\n                  key=\"back\"\n                  type=\"button\"\n                  aria-label=\"Back\"\n                  onClick={nav.back}\n                  initial={{ opacity: 0, width: 0, scale: 0.6 }}\n                  animate={{ opacity: 1, width: 32, scale: 1 }}\n                  exit={{ opacity: 0, width: 0, scale: 0.6 }}\n                  transition={{ type: \"spring\", stiffness: 420, damping: 32 }}\n                  className=\"grid size-8 shrink-0 place-items-center rounded-[10px] text-ink-muted outline-none hover:bg-surface-hover hover:text-ink focus-visible:shadow-[var(--focus-ring)]\"\n                >\n                  <ChevronLeft className=\"size-[18px]\" aria-hidden />\n                </motion.button>\n              )}\n            </AnimatePresence>\n            <div className=\"relative min-w-0 flex-1\">\n              <AnimatePresence mode=\"popLayout\" initial={false} custom={custom}>\n                <motion.h2\n                  key={view?.id}\n                  ref={(el: HTMLHeadingElement | null) => {\n                    if (el) headingRef.current = el;\n                  }}\n                  id={titleId}\n                  tabIndex={-1}\n                  custom={custom}\n                  variants={titleVariants}\n                  initial=\"enter\"\n                  animate=\"center\"\n                  exit=\"exit\"\n                  transition={{ type: \"spring\", stiffness: 420, damping: 34 }}\n                  className=\"truncate px-1 font-display text-[17px] font-semibold leading-8 tracking-[-0.2px] outline-none\"\n                >\n                  {view?.title}\n                </motion.h2>\n              </AnimatePresence>\n            </div>\n            <button\n              type=\"button\"\n              aria-label=\"Close\"\n              onClick={() => closeRef.current()}\n              className=\"grid size-8 shrink-0 place-items-center rounded-[10px] text-ink-subtle outline-none hover:bg-surface-hover hover:text-ink focus-visible:shadow-[var(--focus-ring)]\"\n            >\n              <X className=\"size-4\" aria-hidden />\n            </button>\n          </div>\n          <motion.div\n            className=\"relative overflow-hidden\"\n            initial={false}\n            animate={{ height }}\n            transition={reduce ? { duration: 0 } : HEIGHT_SPRING}\n          >\n            <div ref={bodyRef} className=\"relative\">\n              <AnimatePresence mode=\"popLayout\" initial={false} custom={custom}>\n                <motion.div\n                  key={view?.id}\n                  custom={custom}\n                  variants={viewVariants}\n                  initial=\"enter\"\n                  animate=\"center\"\n                  exit=\"exit\"\n                  transition={{ type: \"spring\", stiffness: 380, damping: 34 }}\n                  className=\"px-5 pb-5\"\n                >\n                  {view?.description && (\n                    <p id={descId} className=\"mb-4 text-[13.5px] leading-5 text-ink-muted\">\n                      {view.description}\n                    </p>\n                  )}\n                  <NavContext.Provider value={nav}>\n                    {typeof view?.content === \"function\" ? view.content(nav) : view?.content}\n                  </NavContext.Provider>\n                </motion.div>\n              </AnimatePresence>\n            </div>\n          </motion.div>\n        </motion.div>\n      </motion.div>\n    </div>\n  );\n}\n"},{"path":"LICENSE-geturui.md","type":"registry:file","target":"LICENSE-geturui.md","content":"MIT License\n\nCopyright (c) 2026 geturui.com\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"}],"categories":["components","modal","dialog","overlay","layout animation","shared layout","settings","navigation"],"meta":{"tier":"free","license":"MIT","docs":"https://geturui.com/docs/morphing-modal"}}