{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "morph-dialog",
  "type": "registry:ui",
  "title": "Morph Dialog",
  "description": "A card that opens into its dialog and folds back into the grid.",
  "categories": [
    "dialogs"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/morph-dialog.tsx",
      "content": "\"use client\";\nimport { Dialog } from \"@base-ui/react/dialog\";\nimport { AnimatePresence, motion, useReducedMotion, type HTMLMotionProps, type Transition } from \"motion/react\";\nimport { createContext, use, useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { X } from \"@/lib/icons\";\n\ntype Ctx = {\n  id: string;\n  open: boolean;\n  layout: Transition;\n  reduce: boolean;\n  instant: boolean;\n  settling: boolean;\n  setSettling: (v: boolean) => void;\n  container?: Dialog.Portal.Props[\"container\"];\n};\nconst MorphContext = createContext<Ctx | null>(null);\n// Shared parts render differently in the card and in the dialog.\nconst WhereContext = createContext<\"trigger\" | \"content\">(\"trigger\");\n\nconst useMorph = () => {\n  const ctx = use(MorphContext);\n  if (!ctx) throw new Error(\"MorphDialog parts must be inside <MorphDialog>\");\n  return ctx;\n};\n\nexport type MorphDialogProps = {\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /**\n   * Render the dialog inside this element instead of over the whole page: the backdrop covers\n   * only the container and page scroll isn't locked. The container needs position: relative.\n   */\n  container?: Dialog.Portal.Props[\"container\"];\n  children: React.ReactNode;\n};\n\nexport function MorphDialog({ open: openProp, defaultOpen = false, onOpenChange, container, children }: MorphDialogProps) {\n  const id = useId();\n  const reduce = !!useReducedMotion();\n  const [open, setOpen] = useControllableState({ value: openProp, defaultValue: defaultOpen, onChange: onOpenChange });\n  const [instant, setInstant] = useState(false);\n  const [settling, setSettling] = useState(false);\n  // Opening springs softly; closing folds back faster. Escape and reduced motion skip the morph.\n  const layout: Transition = reduce || instant ? { duration: 0 } : open ? spring.soft : spring.snappy;\n\n  return (\n    <MorphContext value={{ id, open, layout, reduce, instant, settling, setSettling, container }}>\n      <Dialog.Root\n        open={open}\n        modal={container ? \"trap-focus\" : true}\n        onOpenChange={(next, details) => {\n          setInstant(!next && details.reason === \"escape-key\");\n          setOpen(next);\n        }}\n      >\n        {children}\n      </Dialog.Root>\n    </MorphContext>\n  );\n}\n\nexport type MorphDialogTriggerProps = Omit<Dialog.Trigger.Props, \"className\"> & { className?: string };\n\n/** The card. Its surface, image, title and subtitle travel into the dialog and back. */\nexport function MorphDialogTrigger({ className, children, ...rest }: MorphDialogTriggerProps) {\n  const { id, open, layout, settling, setSettling } = useMorph();\n  return (\n    <WhereContext value=\"trigger\">\n      <Dialog.Trigger\n        data-state={open ? \"open\" : \"closed\"}\n        className={cn(\n          \"group/card relative flex flex-col rounded-xl p-1 text-left outline-none\",\n          \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n          \"transition-[scale] duration-150 active:scale-[0.98] active:duration-75\",\n          // Above its neighbors while it flies back into place.\n          (open || settling) && \"z-(--z-dialog)\",\n          className,\n        )}\n        {...rest}\n      >\n        {open ? (\n          // The slot it left stays visible, so it's clear where the dialog will fold back to.\n          <span aria-hidden className=\"absolute inset-0 rounded-xl bg-fg/[0.03]\" />\n        ) : (\n          <motion.span\n            aria-hidden\n            layoutId={`${id}-surface`}\n            transition={layout}\n            onLayoutAnimationStart={() => setSettling(true)}\n            onLayoutAnimationComplete={() => setSettling(false)}\n            style={{ borderRadius: 12 }}\n            className=\"absolute inset-0 border border-line-2 bg-raised shadow-[var(--shadow)] transition-[border-color,background-color] duration-150 group-hover/card:border-fg-4\"\n          />\n        )}\n        <span className=\"relative flex flex-col\">{children}</span>\n      </Dialog.Trigger>\n    </WhereContext>\n  );\n}\n\nexport type MorphDialogContentProps = Omit<Dialog.Popup.Props, \"className\" | \"render\"> & {\n  className?: string;\n  /** Max width in px. The dialog is never wider than its container less 32px. */\n  width?: number;\n  closeLabel?: string;\n};\n\nexport function MorphDialogContent({ className, width = 420, closeLabel = \"Close\", children, ...rest }: MorphDialogContentProps) {\n  const { id, open, layout, reduce, instant, container } = useMorph();\n  const popupRef = useRef<HTMLDivElement>(null);\n  const contained = container != null;\n  const fade = { duration: reduce ? 0.12 : 0.2, ease: ease.out };\n  return (\n    <AnimatePresence>\n      {open && (\n        <Dialog.Portal keepMounted container={container}>\n          <WhereContext value=\"content\">\n            <Dialog.Backdrop\n              render={\n                <motion.div\n                  initial={{ opacity: 0 }}\n                  animate={{ opacity: 1, transition: fade }}\n                  exit={{ opacity: 0, transition: { duration: instant ? 0 : 0.18, ease: ease.out } }}\n                />\n              }\n              className={cn(contained ? \"absolute\" : \"fixed\", \"inset-0 z-(--z-overlay) bg-overlay\")}\n            />\n            <Dialog.Viewport className={cn(contained ? \"absolute\" : \"fixed\", \"inset-0 z-(--z-dialog) grid place-items-center p-4\")}>\n              <Dialog.Popup\n                ref={popupRef}\n                initialFocus={popupRef}\n                className={cn(\"relative flex max-h-full w-full min-w-0 flex-col text-fg outline-none\", className)}\n                style={{ maxWidth: width }}\n                {...rest}\n              >\n                <motion.div\n                  aria-hidden\n                  layoutId={`${id}-surface`}\n                  transition={layout}\n                  style={{ borderRadius: 16 }}\n                  exit={{ opacity: instant ? 0 : 1, transition: { duration: 0 } }}\n                  className=\"absolute inset-0 border border-line-2 bg-raised shadow-pop\"\n                />\n                <div className=\"relative flex min-h-0 flex-col p-1.5\">{children}</div>\n                {/* Last in the DOM so Tab reaches the content first; first thing the eye finds, over the image. */}\n                <MorphDialogFade className=\"absolute right-3.5 top-3.5\">\n                  <Dialog.Close\n                    aria-label={closeLabel}\n                    className={cn(\n                      \"relative grid size-7 place-items-center rounded-full border border-line-2 bg-raised/90 text-fg-2 shadow-[var(--shadow)] outline-none\",\n                      \"transition-[background-color,color,scale] duration-150 hover:bg-hover hover:text-fg active:scale-[0.92] active:duration-75\",\n                      \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n                      \"before:absolute before:-inset-2 before:content-[''] pointer-fine:before:hidden\",\n                    )}\n                  >\n                    <X size={14} />\n                  </Dialog.Close>\n                </MorphDialogFade>\n              </Dialog.Popup>\n            </Dialog.Viewport>\n          </WhereContext>\n        </Dialog.Portal>\n      )}\n    </AnimatePresence>\n  );\n}\n\ntype SharedProps = { className?: string; children?: React.ReactNode };\n\n// A part that exists in both the card and the dialog under one layoutId. While the dialog\n// is open, the card keeps an invisible copy so its size never changes.\nfunction Shared({ name, radius, className, children, as = \"div\" }: SharedProps & { name: string; radius?: number; as?: \"div\" | \"p\" }) {\n  const { id, open, layout } = useMorph();\n  const where = use(WhereContext);\n  // Inside the card (a button) everything is phrasing content.\n  const Tag = where === \"trigger\" ? \"span\" : as;\n  if (where === \"trigger\" && open)\n    return (\n      <Tag aria-hidden className={cn(className, \"invisible\")}>\n        {children}\n      </Tag>\n    );\n  const M = motion[Tag];\n  return (\n    <M layoutId={`${id}-${name}`} transition={layout} style={radius != null ? { borderRadius: radius } : undefined} className={cn(\"block\", className)}>\n      {children}\n    </M>\n  );\n}\n\n/** The image. Keep the same aspect ratio in the card and the dialog so it scales without stretching. */\nexport function MorphDialogImage({ className, children, aspect = \"2 / 1\" }: SharedProps & { aspect?: string }) {\n  const where = use(WhereContext);\n  return (\n    <Shared name=\"image\" radius={where === \"trigger\" ? 8 : 10} className={cn(\"relative w-full shrink-0 overflow-hidden bg-frame\", className)}>\n      <span className=\"block\" style={{ aspectRatio: aspect }}>\n        {children}\n      </span>\n    </Shared>\n  );\n}\n\nexport function MorphDialogTitle({ className, children }: SharedProps) {\n  const where = use(WhereContext);\n  const { id, layout } = useMorph();\n  if (where === \"content\")\n    return (\n      <Dialog.Title\n        render={(props) => <motion.h2 {...(props as HTMLMotionProps<\"h2\">)} layoutId={`${id}-title`} transition={layout} />}\n        className={cn(\"w-fit max-w-full text-[17px] font-medium leading-6 tracking-[-0.02em] text-fg text-balance\", className)}\n      >\n        {children}\n      </Dialog.Title>\n    );\n  // w-fit keeps the box the size of the words, so it scales uniformly between sizes.\n  return (\n    <Shared name=\"title\" className={cn(\"w-fit max-w-full truncate text-[13px] font-medium leading-5 tracking-[-0.01em] text-fg\", className)}>\n      {children}\n    </Shared>\n  );\n}\n\nexport function MorphDialogSubtitle({ className, children }: SharedProps) {\n  const where = use(WhereContext);\n  return (\n    <Shared name=\"subtitle\" as=\"p\" className={cn(\"w-fit max-w-full truncate leading-[18px] text-fg-3\", where === \"trigger\" ? \"text-[12px]\" : \"text-[12.5px]\", className)}>\n      {children}\n    </Shared>\n  );\n}\n\n/** Everything that only exists in the dialog: it fades in once the shared parts are on their way. */\nexport function MorphDialogFade({ className, children }: SharedProps) {\n  const { reduce, instant } = useMorph();\n  return (\n    <motion.div\n      // Held back until the surface has nearly arrived, so nothing shows outside it mid-flight.\n      initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8, filter: \"blur(2px)\" }}\n      animate={{ opacity: 1, y: 0, filter: \"blur(0px)\", transition: { duration: reduce ? 0.12 : 0.22, delay: reduce ? 0 : 0.16, ease: ease.out } }}\n      exit={{ opacity: 0, transition: { duration: instant ? 0 : 0.08 } }}\n      className={className}\n    >\n      {children}\n    </motion.div>\n  );\n}\n\nexport type MorphDialogDescriptionProps = Omit<Dialog.Description.Props, \"className\"> & { className?: string };\n\nexport function MorphDialogDescription({ className, ...rest }: MorphDialogDescriptionProps) {\n  return <Dialog.Description className={cn(\"text-[13px] leading-5 text-fg-2 text-pretty\", className)} {...rest} />;\n}\n\nexport type MorphDialogCloseProps = Omit<Dialog.Close.Props, \"className\"> & { className?: string };\n\n/** A close control of your own, e.g. a \"Cancel\" button in the dialog's actions. */\nexport function MorphDialogClose({ className, ...rest }: MorphDialogCloseProps) {\n  return <Dialog.Close className={className} {...rest} />;\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/morph-dialog"
}
