{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dialog-emerge",
  "title": "Dialog Emerge",
  "description": "A native <dialog> modal that grows out of the control that opened it and returns into it on close.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/dialog-emerge/component.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useId, useRef, type ReactNode, type RefObject } from \"react\";\n\n// A modal on the native <dialog> element: focus trap, background inertness to\n// pointer and focus, Escape-to-close, top-layer stacking and ::backdrop all\n// come from the platform, so none of that is reimplemented here. showModal()\n// does NOT stop the page behind it from scrolling, though, so that one piece\n// of inertness is hand-rolled: body scroll is locked for the duration the\n// dialog is open and the scrollbar's width is compensated with padding so the\n// page doesn't jump when the scrollbar disappears. The twist is the entrance —\n// the panel emerges from the control that opened it. The trigger's bounding\n// rect is measured at open time (not at mount; it moves on scroll and resize),\n// inverted into one transform, and played out to identity (FLIP: no layout\n// thrash, one composited property). Closing runs the same transform backwards\n// so the panel returns into the trigger and the user never loses the thread of\n// what they clicked. No trigger — opened programmatically — falls back to a\n// centered scale-in. The close is timer-driven rather than transitionend-driven\n// so a backgrounded tab can't strand an un-closed dialog. Reduced motion skips\n// the morph entirely and opens instantly, still fully functional.\n\nconst OPEN_MS = 340;\nconst CLOSE_MS = 220;\nconst OPEN_EASE = \"cubic-bezier(0.22, 1, 0.36, 1)\";\nconst CLOSE_EASE = \"cubic-bezier(0.4, 0, 0.9, 0.4)\";\nconst PANEL_RADIUS = 12; // --radius-md, kept visually constant through the scale\nconst FALLBACK_SCALE = 0.92; // centered scale-in when there is no trigger\n\nexport interface EmergeDialogProps {\n  /** Controlled open state. The dialog is opened with `showModal()`. */\n  open: boolean;\n  /** Called for every close the platform can originate: Escape, backdrop, close controls. */\n  onOpenChange: (open: boolean) => void;\n  /** The control the panel emerges from and returns into. Omit for a centered scale-in. */\n  triggerRef?: RefObject<HTMLElement | null>;\n  /** dialog heading */\n  title?: ReactNode;\n  /** supporting copy under the title */\n  description?: ReactNode;\n  /** Body and actions. Rendered inside the panel, below the title block. */\n  children?: ReactNode;\n  /** Clicking the dimmed backdrop closes. Default true. */\n  dismissOnBackdrop?: boolean;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nexport function EmergeDialog({\n  open,\n  onOpenChange,\n  triggerRef,\n  title,\n  description,\n  children,\n  dismissOnBackdrop = true,\n  className = \"\",\n}: EmergeDialogProps) {\n  const dialogRef = useRef<HTMLDialogElement>(null);\n  const panelRef = useRef<HTMLDivElement>(null);\n  const rafRef = useRef(0);\n  const timerRef = useRef(0);\n  const titleId = useId();\n  const descId = useId();\n  // Captured inline body styles from just before the lock was applied, so\n  // close/unmount restores whatever a host page had set rather than clobbering\n  // it. Null means \"not currently locked\".\n  const scrollLockRef = useRef<{ overflow: string; paddingRight: string } | null>(null);\n\n  // read in listeners that are bound once, so their deps stay empty\n  const openRef = useRef(open);\n  openRef.current = open;\n  const onOpenChangeRef = useRef(onOpenChange);\n  onOpenChangeRef.current = onOpenChange;\n  const dismissRef = useRef(dismissOnBackdrop);\n  dismissRef.current = dismissOnBackdrop;\n\n  const reset = useCallback(() => {\n    const dlg = dialogRef.current;\n    const panel = panelRef.current;\n    if (dlg) {\n      dlg.removeAttribute(\"data-closing\");\n      dlg.style.transition = \"\";\n      dlg.style.transform = \"\";\n      dlg.style.borderRadius = \"\";\n    }\n    if (panel) {\n      panel.style.transition = \"\";\n      panel.style.opacity = \"\";\n      panel.style.transform = \"\";\n    }\n  }, []);\n\n  // The inverted transform that maps the dialog's final rect onto the trigger's.\n  const invert = useCallback(() => {\n    const dlg = dialogRef.current;\n    if (!dlg) return null;\n    const d = dlg.getBoundingClientRect();\n    if (!d.width || !d.height) return null;\n    const t = triggerRef?.current?.getBoundingClientRect();\n    if (!t || !t.width || !t.height) {\n      return { sx: FALLBACK_SCALE, sy: FALLBACK_SCALE, dx: 0, dy: 0 };\n    }\n    return {\n      sx: Math.max(0.05, t.width / d.width),\n      sy: Math.max(0.05, t.height / d.height),\n      dx: t.left + t.width / 2 - (d.left + d.width / 2),\n      dy: t.top + t.height / 2 - (d.top + d.height / 2),\n    };\n  }, [triggerRef]);\n\n  // Counter the scale so the corner radius reads the same size all the way\n  // through the morph instead of ballooning as the panel shrinks.\n  const morph = (dlg: HTMLDialogElement, m: { sx: number; sy: number; dx: number; dy: number }) => {\n    dlg.style.transform = `translate(${m.dx.toFixed(1)}px, ${m.dy.toFixed(1)}px) scale(${m.sx.toFixed(4)}, ${m.sy.toFixed(4)})`;\n    dlg.style.borderRadius = `${(PANEL_RADIUS / ((m.sx + m.sy) / 2)).toFixed(1)}px`;\n  };\n\n  const reduced = () =>\n    typeof window !== \"undefined\" && window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n  // showModal() and CSS inertness stop pointer and focus from reaching the\n  // background, but the page itself keeps scrolling under it. Lock it here;\n  // the scrollbar's width is added back as body padding so the page doesn't\n  // reflow/jump when the scrollbar disappears. Idempotent: a re-open landing\n  // mid-close finds it already locked and no-ops.\n  const lockScroll = useCallback(() => {\n    if (scrollLockRef.current) return;\n    const body = document.body;\n    const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;\n    scrollLockRef.current = { overflow: body.style.overflow, paddingRight: body.style.paddingRight };\n    body.style.overflow = \"hidden\";\n    if (scrollbarWidth > 0) {\n      const currentPadding = parseFloat(window.getComputedStyle(body).paddingRight) || 0;\n      body.style.paddingRight = `${currentPadding + scrollbarWidth}px`;\n    }\n  }, []);\n\n  const unlockScroll = useCallback(() => {\n    const saved = scrollLockRef.current;\n    if (!saved) return;\n    const body = document.body;\n    body.style.overflow = saved.overflow;\n    body.style.paddingRight = saved.paddingRight;\n    scrollLockRef.current = null;\n  }, []);\n\n  const openNow = useCallback(() => {\n    const dlg = dialogRef.current;\n    const panel = panelRef.current;\n    if (!dlg) return;\n    if (dlg.open && !dlg.hasAttribute(\"data-closing\")) return;\n    // a re-open landing mid-close must not inherit the close's transform, and\n    // must kill the pending timer that would otherwise close it a frame later\n    clearTimeout(timerRef.current);\n    cancelAnimationFrame(rafRef.current);\n    reset();\n    lockScroll();\n    if (!dlg.open) dlg.showModal();\n    if (reduced()) return;\n\n    const m = invert();\n    if (!m) return;\n    dlg.style.transition = \"none\";\n    morph(dlg, m);\n    if (panel) {\n      panel.style.transition = \"none\";\n      panel.style.opacity = \"0\";\n      panel.style.transform = \"translateY(6px)\";\n    }\n    // rect measurement above already flushed layout, so one frame is enough\n    rafRef.current = requestAnimationFrame(() => {\n      dlg.style.transition = `transform ${OPEN_MS}ms ${OPEN_EASE}, border-radius ${OPEN_MS}ms ${OPEN_EASE}`;\n      dlg.style.transform = \"translate(0px, 0px) scale(1, 1)\";\n      dlg.style.borderRadius = \"\";\n      if (panel) {\n        // content arrives after the box has most of its size — the squash of\n        // real text through a 0.2 scale is what makes a morph look cheap\n        panel.style.transition = `opacity 200ms ease-out 110ms, transform 240ms ${OPEN_EASE} 90ms`;\n        panel.style.opacity = \"1\";\n        panel.style.transform = \"translateY(0px)\";\n      }\n    });\n  }, [invert, reset, lockScroll]);\n\n  const closeNow = useCallback(() => {\n    const dlg = dialogRef.current;\n    const panel = panelRef.current;\n    if (!dlg || !dlg.open || dlg.hasAttribute(\"data-closing\")) return;\n    cancelAnimationFrame(rafRef.current);\n    if (reduced()) {\n      reset();\n      dlg.close();\n      return;\n    }\n    const m = invert();\n    if (!m) {\n      dlg.close();\n      return;\n    }\n    dlg.setAttribute(\"data-closing\", \"\");\n    dlg.style.transition = `transform ${CLOSE_MS}ms ${CLOSE_EASE}, border-radius ${CLOSE_MS}ms ${CLOSE_EASE}`;\n    morph(dlg, m);\n    if (panel) {\n      panel.style.transition = \"opacity 130ms ease-in\";\n      panel.style.opacity = \"0\";\n    }\n    timerRef.current = window.setTimeout(() => {\n      reset();\n      dlg.close();\n    }, CLOSE_MS + 40);\n  }, [invert, reset]);\n\n  useEffect(() => {\n    if (open) openNow();\n    else closeNow();\n  }, [open, openNow, closeNow]);\n\n  useEffect(() => {\n    const dlg = dialogRef.current;\n    if (!dlg) return;\n    // Escape fires `cancel`; take it over so the return-to-trigger still plays.\n    const onCancel = (e: Event) => {\n      e.preventDefault();\n      onOpenChangeRef.current(false);\n    };\n    // anything that closed the dialog without going through the prop. The\n    // native `close` event fires for every path the dialog can close through\n    // (our own dlg.close() calls included), so it's the single place the\n    // scroll lock is released rather than duplicating that call at every\n    // dlg.close() site.\n    const onClose = () => {\n      unlockScroll();\n      if (openRef.current) onOpenChangeRef.current(false);\n    };\n    // a click on the backdrop targets the dialog itself; the panel covers the rest\n    const onClick = (e: MouseEvent) => {\n      if (dismissRef.current && e.target === dlg) onOpenChangeRef.current(false);\n    };\n    dlg.addEventListener(\"cancel\", onCancel);\n    dlg.addEventListener(\"close\", onClose);\n    dlg.addEventListener(\"click\", onClick);\n    return () => {\n      dlg.removeEventListener(\"cancel\", onCancel);\n      dlg.removeEventListener(\"close\", onClose);\n      dlg.removeEventListener(\"click\", onClick);\n    };\n  }, [unlockScroll]);\n\n  useEffect(\n    () => () => {\n      clearTimeout(timerRef.current);\n      cancelAnimationFrame(rafRef.current);\n      // Unmounting mid-animation (before the close timer fires dlg.close())\n      // must not strand the page scroll-locked forever.\n      unlockScroll();\n    },\n    [unlockScroll]\n  );\n\n  return (\n    <dialog\n      ref={dialogRef}\n      aria-labelledby={title ? titleId : undefined}\n      aria-describedby={description ? descId : undefined}\n      className={[\n        \"ns-emerge m-auto w-[min(30rem,calc(100vw-2rem))] overflow-hidden p-0\",\n        \"rounded-md border border-border bg-surface text-foreground shadow-2xl\",\n        \"will-change-transform\",\n        className,\n      ].join(\" \")}\n    >\n      {/* showModal() focuses the first focusable descendant unless something\n          claims it. Left alone that is whatever control happens to come first —\n          on a destructive dialog it pre-selects an option the user never chose,\n          and paints a focus ring on it at rest. Take it here: focus lands\n          inside the dialog (screen readers announce the title, Tab starts at\n          the top) without preselecting anything. */}\n      <div\n        ref={panelRef}\n        autoFocus\n        tabIndex={-1}\n        className=\"flex flex-col gap-5 p-6 outline-none\"\n      >\n        {(title || description) && (\n          <div className=\"flex flex-col gap-2\">\n            {title && (\n              <h2 id={titleId} className=\"text-base font-semibold tracking-tight\">\n                {title}\n              </h2>\n            )}\n            {description && (\n              <p id={descId} className=\"text-sm leading-relaxed text-ns-muted\">\n                {description}\n              </p>\n            )}\n          </div>\n        )}\n        {children}\n      </div>\n\n      {/* ::backdrop inherits custom properties from its originating element, so\n          the scrim stays token-derived: ink-over-paper in light, paper's own\n          near-black in dark. Dim only — a blur costs a compositor layer and\n          reads as generic. */}\n      <style>{`\n        .ns-emerge { --ns-scrim: color-mix(in srgb, var(--foreground) 22%, transparent); }\n        :where(.dark) .ns-emerge { --ns-scrim: color-mix(in srgb, var(--background) 66%, transparent); }\n        .ns-emerge::backdrop { background: var(--ns-scrim); animation: ns-emerge-scrim-in ${OPEN_MS}ms ease-out forwards; }\n        .ns-emerge[data-closing]::backdrop { animation: ns-emerge-scrim-out ${CLOSE_MS}ms ease-in forwards; }\n        @keyframes ns-emerge-scrim-in { from { opacity: 0 } to { opacity: 1 } }\n        @keyframes ns-emerge-scrim-out { from { opacity: 1 } to { opacity: 0 } }\n        @media (prefers-reduced-motion: reduce) {\n          .ns-emerge::backdrop, .ns-emerge[data-closing]::backdrop { animation: none; opacity: 1 }\n        }\n      `}</style>\n    </dialog>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/dialog-emerge.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-surface": "var(--surface)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "surface": "#fafafa"
    },
    "dark": {
      "ns-muted": "#8f8f8f",
      "surface": "#171717"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "dialog",
      "modal",
      "overlay",
      "flip",
      "accessibility"
    ],
    "instruction": "A controlled modal built on the native <dialog> element opened with showModal(), so the focus trap, background inertness to pointer and focus, Escape-to-close, top-layer stacking and ::backdrop come from the platform rather than a hand-rolled implementation. One piece of background inertness the platform does not provide is scroll: showModal() leaves the page behind the dialog free to scroll, so that is hand-rolled — body scroll is locked for as long as the dialog is open (captured/restored against whatever inline style was already on the body, not clobbered) and released on close and on unmount even mid-close-animation, with the vanishing scrollbar's width compensated as body padding so the page doesn't jump width when the lock engages. The entrance is a FLIP morph: the trigger element's bounding rect is measured at open time (never at mount — it moves on scroll and resize), inverted into a single translate+scale transform on the dialog, and played out to identity, with the corner radius counter-scaled so it reads constant and the panel content fading in slightly late so real text never squashes through a small scale. Closing runs the same transform backwards so the panel returns into the trigger; the close is timer-driven rather than transitionend-driven, so a backgrounded tab can never strand an un-closed dialog, and a re-open landing mid-close cancels the pending timer and resets styles first. With no triggerRef (opened programmatically) it falls back to a centered scale-in. Escape's `cancel` event is intercepted so the return-to-origin still plays, backdrop clicks dismiss (opt-out via dismissOnBackdrop), and any close the component didn't originate is reported back through onOpenChange. The backdrop is a token-derived dim with no blur — ink-over-paper in light, the background token's own near-black in dark — set via a custom property that ::backdrop inherits from the dialog. prefers-reduced-motion skips the morph and the scrim fade entirely and opens instantly, fully functional.",
    "rank": 6
  },
  "type": "registry:ui"
}