{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"dialog","type":"registry:component","title":"Dialog","description":"A decision that needs the whole screen: the page fades back under a veil of its own colour and the panel rises out of a blur.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/ui-interaction-motion.json?as=dependency"],"files":[{"path":"registry/dialog.tsx","type":"registry:component","content":"// Hairline UI · dialog · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { MODAL_ROOT, SCRIM, useModal } from \"./modal\";\nimport { EASE_OUT, INTERACTION_FOCUS, INTERACTION_PRESS, useInteractionReducedMotion } from \"./ui-interaction-motion\";\n\n/* ─────────────────────────────────────────────────────────\n * DIALOG: a decision that needs the whole screen\n *\n *   open     the page fades back under a veil of its own\n *            background (no blur); the panel rises 8px, out of a\n *            4px blur, from 0.96\n *   close    Escape, the ✕ or a press on the veil; it sinks back\n *            the way it came and focus returns to what opened it\n *   long     the body scrolls inside the panel; the page behind\n *            never moves\n *\n * The native <dialog>: focus stays inside, the page is inert,\n * and it sits above everything without z-index games.\n * ───────────────────────────────────────────────────────── */\n\nexport interface DialogProps {\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** A button that opens it. */\n  trigger?: React.ReactElement<React.HTMLAttributes<HTMLElement>>;\n  title: React.ReactNode;\n  description?: React.ReactNode;\n  /** The body, or a function that gets `close`. */\n  children?: React.ReactNode | ((api: { close: () => void }) => React.ReactNode);\n  /** Buttons along the bottom, or a function that gets `close`. */\n  footer?: React.ReactNode | ((api: { close: () => void }) => React.ReactNode);\n  /** 400, 520 or 680px wide. */\n  size?: \"sm\" | \"md\" | \"lg\";\n  /** Press on the veil closes it. Turn off for forms that would lose work. */\n  dismissible?: boolean;\n  className?: string;\n}\n\nconst WIDTH = { sm: 400, md: 520, lg: 680 };\n\nexport function Dialog({\n  open: controlled,\n  defaultOpen = false,\n  onOpenChange,\n  trigger,\n  title,\n  description,\n  children,\n  footer,\n  size = \"md\",\n  dismissible = true,\n  className = \"\",\n}: DialogProps) {\n  const reduced = useInteractionReducedMotion();\n  const [own, setOwn] = React.useState(defaultOpen);\n  const open = controlled ?? own;\n  const setOpen = (next: boolean) => {\n    if (controlled === undefined) setOwn(next);\n    onOpenChange?.(next);\n  };\n  const close = () => setOpen(false);\n  const { dialog, mounted, shown } = useModal(open, close, 220);\n  const titleId = React.useId();\n  const descId = React.useId();\n  const ease = EASE_OUT;\n\n  return (\n    <>\n      {trigger &&\n        React.cloneElement(trigger, {\n          \"aria-haspopup\": \"dialog\",\n          onClick: (e: React.MouseEvent<HTMLElement>) => {\n            trigger.props.onClick?.(e);\n            setOpen(true);\n          },\n        })}\n      {mounted && (\n        <dialog ref={dialog} aria-labelledby={titleId} aria-describedby={description ? descId : undefined} className={MODAL_ROOT}>\n          <div\n            className={SCRIM}\n            onClick={() => dismissible && close()}\n            style={{ opacity: shown ? 1 : 0, transition: `opacity ${shown ? 260 : 200}ms ${ease}` }}\n          />\n          <div className=\"pointer-events-none absolute inset-0 flex items-end justify-center p-3 sm:items-center sm:p-6\">\n            <div\n              className={`pointer-events-auto relative flex max-h-[min(85dvh,760px)] w-full flex-col rounded-[22px] bg-background shadow-[0_0_0_1px_var(--border)] ${className}`}\n              style={{\n                maxWidth: WIDTH[size],\n                opacity: shown ? 1 : 0,\n                translate: shown || reduced ? \"0 0\" : \"0 8px\",\n                scale: shown || reduced ? \"1\" : \"0.96\",\n                filter: shown || reduced ? \"blur(0px)\" : \"blur(4px)\",\n                transition: reduced\n                  ? \"opacity 160ms ease-out\"\n                  : shown\n                    ? `opacity 240ms ${ease}, translate 420ms ${ease}, scale 420ms ${ease}, filter 240ms ${ease}`\n                    : `opacity 180ms ${ease}, translate 220ms ${ease}, scale 220ms ${ease}, filter 180ms ${ease}`,\n              }}\n            >\n              <div className=\"flex items-start gap-4 px-6 pb-2 pt-5\">\n                <div className=\"flex min-w-0 flex-1 flex-col gap-1\">\n                  <h2 id={titleId} className=\"text-[16px] font-semibold tracking-[-0.01em] text-foreground\">\n                    {title}\n                  </h2>\n                  {description && (\n                    <p id={descId} className=\"text-[13.5px] leading-relaxed text-muted-foreground\">\n                      {description}\n                    </p>\n                  )}\n                </div>\n                <button\n                  type=\"button\"\n                  aria-label=\"Close\"\n                  onClick={close}\n                  className={`-mr-2 flex size-8 shrink-0 items-center justify-center rounded-full text-muted-foreground cursor-pointer hover:bg-accent hover:text-foreground ${INTERACTION_PRESS} ${INTERACTION_FOCUS}`}\n                >\n                  <svg viewBox=\"0 0 16 16\" fill=\"none\" className=\"size-3.5\">\n                    <path d=\"M4 4l8 8M12 4l-8 8\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" />\n                  </svg>\n                </button>\n              </div>\n              {children !== undefined && (\n                <div className=\"min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-2 text-[13.5px] leading-relaxed text-foreground\">\n                  {typeof children === \"function\" ? children({ close }) : children}\n                </div>\n              )}\n              {footer !== undefined && (\n                <div className=\"flex flex-col-reverse gap-2 px-6 pb-5 pt-4 sm:flex-row sm:justify-end\">{typeof footer === \"function\" ? footer({ close }) : footer}</div>\n              )}\n              {footer === undefined && <div className=\"h-4\" />}\n            </div>\n          </div>\n        </dialog>\n      )}\n    </>\n  );\n}\n\nexport default Dialog;\n"},{"path":"registry/modal.ts","type":"registry:component","content":"// Hairline UI · dialog · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * MODAL: the native <dialog> under Dialog and Sheet\n *\n *   open     showModal() (focus trap, inert page, top layer) and\n *            the next frame turns `shown` on for the entry motion\n *   close    Escape and the scrim ask to close; the surface plays\n *            its exit, then dialog.close() and focus goes back to\n *            whatever opened it\n *   scroll   wheel and touch scrolling outside a scrollable part\n *            of the panel are stopped at the dialog, so the page\n *            behind never moves (the page's own styles are never\n *            touched: no layout shift, no restyle of <html>)\n * ───────────────────────────────────────────────────────── */\n\nexport function useModal(open: boolean, onClose: () => void, exit = 220) {\n  const dialog = React.useRef<HTMLDialogElement>(null);\n  const [mounted, setMounted] = React.useState(open);\n  const [shown, setShown] = React.useState(false);\n  const opener = React.useRef<HTMLElement | null>(null);\n  const close = React.useRef(onClose);\n  React.useEffect(() => {\n    close.current = onClose;\n  });\n  if (open && !mounted) setMounted(true);\n\n  React.useEffect(() => {\n    const el = dialog.current;\n    if (!el) return;\n    if (open) {\n      opener.current = document.activeElement as HTMLElement | null;\n      if (!el.open) el.showModal();\n      let inner = 0;\n      const outer = requestAnimationFrame(() => {\n        inner = requestAnimationFrame(() => setShown(true));\n      });\n      return () => {\n        cancelAnimationFrame(outer);\n        cancelAnimationFrame(inner);\n      };\n    }\n    const timer = window.setTimeout(() => {\n      if (el.open) el.close();\n      setMounted(false);\n      setShown(false);\n      opener.current?.focus?.({ preventScroll: true });\n    }, exit);\n    return () => window.clearTimeout(timer);\n  }, [open, mounted, exit]);\n\n  React.useEffect(() => {\n    const el = dialog.current;\n    if (!el || !mounted) return;\n    const onCancel = (e: Event) => {\n      e.preventDefault();\n      close.current();\n    };\n    // Stop scrolling from leaking to the page unless it happens inside something that scrolls.\n    const guard = (e: Event) => {\n      let node = e.target as HTMLElement | null;\n      while (node && node !== el) {\n        const style = getComputedStyle(node);\n        if (/(auto|scroll)/.test(style.overflowY) && node.scrollHeight > node.clientHeight) return;\n        node = node.parentElement;\n      }\n      e.preventDefault();\n    };\n    el.addEventListener(\"cancel\", onCancel);\n    el.addEventListener(\"wheel\", guard, { passive: false });\n    el.addEventListener(\"touchmove\", guard, { passive: false });\n    return () => {\n      el.removeEventListener(\"cancel\", onCancel);\n      el.removeEventListener(\"wheel\", guard);\n      el.removeEventListener(\"touchmove\", guard);\n    };\n  }, [mounted]);\n\n  return { dialog, mounted: mounted || open, shown: open && shown };\n}\n\n/** Class for the full-window <dialog> both surfaces sit in. */\nexport const MODAL_ROOT =\n  \"fixed inset-0 m-0 h-dvh max-h-none w-screen max-w-none overflow-hidden bg-transparent p-0 text-foreground outline-none backdrop:bg-transparent\";\n\n/** The dim behind the surface: the page fades back under a veil of the background colour, no blur. */\nexport const SCRIM = \"absolute inset-0 bg-[color-mix(in_oklab,var(--background)_62%,transparent)]\";\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/dialog. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}