{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dialog",
  "type": "registry:ui",
  "title": "Dialog",
  "description": "The one expressive moment. The panel is placed, not thrown: it rises a short distance and settles, and exits are quicker and never bounce.",
  "dependencies": [
    "@radix-ui/react-dialog",
    "motion"
  ],
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/button.json",
    "https://littmd.vercel.app/r/icons.json",
    "https://littmd.vercel.app/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/dialog.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/dialog 2026.11.06. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Dialog: the expressive moment. \"Placed, not thrown.\"\n *\n * - Radix Dialog is the accessibility layer (focus trap, Esc, focus return,\n *   aria). Everything visual is ours.\n * - Controlled open state plus AnimatePresence plus forceMount, so exits\n *   animate before Radix unmounts anything.\n * - Panel enter (expressive tier): opacity 0 to 1, y from useTravel() to 0,\n *   scale 0.98 to 1. It may bounce slightly, as the tokens allow. Opened by a\n *   press (a button, a menu row), it grows out of what was pressed instead: its\n *   transform-origin sits on the opener (openerBox, @/motion/deal) and it\n *   scales up from GROW (0.9) without travelling, so it comes out of the\n *   button's side and is placed, not thrown; it shrinks back into it on close. The side sheet\n *   travels the same small distance on x, never its own width. When the view has\n *   already spent its expressive moment, tier=\"responsive\" moves it on that tier.\n * - Exit: useExit(tier), quicker and never bouncing.\n * - Flat: a solid surface-1 card (no glass, no hairline), and the phone sheet loses its footer rule.\n * - Overlay: a canvas tint that only fades (instant tier). No blur.\n * - Corners: the panel is rounded-litt-surface; the sheet hugs the right edge, so\n *   only its left corners are rounded. .litt-glass carries the surface radius,\n *   so the right corners are reset to square.\n * - Slots: title, description, body (children), footer. Footer buttons are\n *   right-aligned, 12px apart, primary last. The footer is marked\n *   data-dialog-footer in all three forms (the panel, the side sheet and the\n *   phone sheet share one footer): focus looks for it, and Button's danger\n *   variant reads it, so a destructive dialog's confirm is the one red fill\n *   (R7). Keep the attribute on any footer you write by hand.\n *\n * On compact screens (useDevice().compact, narrower than 640) the dialog is a\n * bottom sheet, and side=\"right\" is the same sheet (foundations.md section 6):\n * - It rises from the bottom edge by its own height, always on the expressive\n *   spring whatever tier says (a sheet does not use up the view's moment), and\n *   leaves the same way on the exit token. Transform only, its height never\n *   animates, and it has one height: its content, up to the visible screen less\n *   --litt-space-6. --litt-space-6 of the sheet hides below the screen edge, so an\n *   overshoot or a pull never shows a gap.\n * - It sits above the keyboard: the layer is the visible screen\n *   (useVisibleScreen), so when the keyboard opens the sheet's bottom rises with\n *   it and the body scrolls inside, never under the keys.\n * - While it is open it counts itself (useSheetPresence), so a toast fired over\n *   it docks at the top edge instead of covering its buttons.\n * - Glass, rounded on the top corners only, a 32 by 4 grabber at the top (hidden\n *   from assistive tech), the body scrolls inside with overscroll-behavior:\n *   contain, and the footer is padded by the safe-area inset.\n * - It follows a finger through useDismissDrag (@/motion): drag down from\n *   anywhere the guards allow. The scrim lightens with it. The close button is\n *   44px on coarse pointers, and Cancel, Esc and a tap on the scrim still close.\n * - On coarse pointers focus lands on the panel, unless a field carries\n *   data-autofocus, so the keyboard does not open under a sheet that is moving.\n *   Fine pointers keep the focus rule above.\n *\n * DialogSheet is that sheet shell, exported so another overlay can reuse it (the\n * Popover on compact screens). It renders the portal, the scrim and the panel\n * inside a Radix Dialog root, and the panel is a grid with the areas \"head close\n * / body body / foot foot\". Put each child in an area with style={{ gridArea }}.\n * Inside it, var(--sheet-foot) is the bottom padding a last row needs: --litt-space-6\n * or the safe-area inset, whichever is larger.\n */\nimport { createContext, useCallback, useContext, useLayoutEffect, useRef, useState, type ReactNode, type Ref } from \"react\";\nimport * as RadixDialog from \"@radix-ui/react-dialog\";\nimport { AnimatePresence, motion, useTransform } from \"motion/react\";\nimport { useDevice, useExit, useMotionDial, useSheetPresence, useTransition, useTravel, useVisibleScreen } from \"../../lib/litt/motion\";\nimport { useDismissDrag } from \"../../lib/litt/motion/drag\";\nimport { GROW, useGrowFrom, useOverlayMotion } from \"../../lib/litt/motion/deal\";\nimport { Button } from \"./button\";\nimport { XIcon } from \"./icons\";\n\n/* Radix's asChild, spread in: shadcn's CLI rewrites a written asChild to Base UI's render prop in base-* hosts. */\nconst asChildProps = { asChild: true } as const;\n\n/* The open state, and the way to change it (a drag that leaves asks through this). */\nconst DialogState = createContext<{ open: boolean; change: (open: boolean) => void }>({ open: false, change: () => {} });\n\nexport type DialogProps = {\n  /** Whether the dialog is open. Pair it with onOpenChange. Leave it out and the dialog keeps its own state. */\n  open?: boolean;\n  /** Start open when you do not control it. */\n  defaultOpen?: boolean;\n  /** Called with the next state when Esc, a click outside, Close, DialogClose or DialogTrigger asks to change it. Ignore a false to keep the dialog open, for example while a save runs. */\n  onOpenChange?: (open: boolean) => void;\n  /** The trigger and the content: a DialogTrigger and a DialogContent. */\n  children: ReactNode;\n};\n\n/** The root. It holds the open state and shares it with DialogTrigger and DialogContent, which go inside it. */\nexport function Dialog({ open: openProp, defaultOpen = false, onOpenChange, children }: DialogProps) {\n  const [inner, setInner] = useState(defaultOpen);\n  const open = openProp ?? inner;\n  const change = useCallback(\n    (next: boolean) => {\n      setInner(next);\n      onOpenChange?.(next);\n    },\n    [onOpenChange],\n  );\n  return (\n    <DialogState value={{ open, change }}>\n      <RadixDialog.Root open={open} onOpenChange={change}>\n        {children}\n      </RadixDialog.Root>\n    </DialogState>\n  );\n}\n\n/** Opens the dialog. Wrap your own Button with asChild, so it keeps its look and gets the open behavior. */\nexport const DialogTrigger = RadixDialog.Trigger;\n/** Closes the dialog. Wrap a Cancel button with asChild. */\nexport const DialogClose = RadixDialog.Close;\n\nexport type DialogContentProps = {\n  /** The heading. It also names the dialog for screen readers, so it is required. */\n  title: ReactNode;\n  /** One line under the title, also read as the dialog's description. Leave it out when the title says enough. */\n  description?: ReactNode;\n  /** Buttons, right-aligned and 12px apart. Primary goes last. */\n  footer?: ReactNode;\n  /** \"center\" is a modal; \"right\" is a settings sheet. On a phone (compact screens) both are the bottom sheet. */\n  side?: \"center\" | \"right\";\n  /** The view's one expressive moment by default. Pass \"responsive\" when the view has already spent it (a list entrance, say). A phone sheet always rises on the expressive spring. */\n  tier?: \"expressive\" | \"responsive\";\n  /** Hide the round close button in the corner. */\n  hideClose?: boolean;\n  /** Extra classes for the panel. */\n  className?: string;\n  /** The body. It scrolls when it is taller than the panel, and the title and footer stay put. Leave it out for a title and buttons only. */\n  children?: ReactNode;\n};\n\nconst FOCUSABLE = \":is(input,textarea,select,button,[href]):not(:disabled)\";\n\n/**\n * Where focus goes when a dialog opens. On a coarse pointer it lands on the panel, or on the field marked\n * data-autofocus, so the keyboard never opens under a panel that is still moving. Otherwise `fine` decides.\n */\nfunction focusOnOpen(event: Event, panel: HTMLElement | null, coarse: boolean, fine?: (event: Event) => void) {\n  if (!coarse) return fine?.(event);\n  if (!panel) return;\n  event.preventDefault();\n  (panel.querySelector<HTMLElement>(\"[data-autofocus]\") ?? panel).focus({ preventScroll: true });\n}\n\n/** Flat: the glass becomes a solid surface-1 card with the modal shadow and no hairline. */\nconst FLAT_PANEL =\n  \"in-[[data-material=flat]]:[--litt-glass-bg:var(--litt-surface-1)] in-[[data-material=flat]]:[--litt-glass-border:transparent] \" +\n  \"in-[[data-material=flat]]:[--litt-highlight:0_0_0_0_transparent] in-[[data-material=flat]]:[--litt-glass-blur:0px] \";\n\nconst SCRIM = \"absolute inset-0 bg-[color-mix(in_oklab,var(--litt-canvas)_55%,transparent)]\";\n\nexport type DialogSheetProps = {\n  /** Whether the sheet is shown. It drives the rise and the exit, so keep rendering the sheet and flip this. */\n  open: boolean;\n  /** Called when a drag decides to leave. Close the surface here: the exit then plays from where the finger let go. */\n  onDismiss: () => void;\n  /** Where focus goes on a fine pointer when the sheet opens (Radix onOpenAutoFocus). On a coarse pointer focus lands on the panel unless a field carries data-autofocus. */\n  onOpenAutoFocus?: (event: Event) => void;\n  /** Set false when nothing inside names a description, so Radix does not look for one. Default true. */\n  described?: boolean;\n  /** The panel element. */\n  contentRef?: Ref<HTMLDivElement>;\n  /** Extra classes for the panel. */\n  className?: string;\n  /** The panel's content. Place each child in a grid area with style={{ gridArea }}: head, close, body, foot. It needs a Radix Dialog.Title. */\n  children: ReactNode;\n};\n\n/**\n * The bottom sheet shell for compact screens: portal, scrim and a draggable glass panel. Render it inside a Radix\n * Dialog root. See the file header for the rules it follows. The Dialog uses it, and so can any overlay that\n * becomes a sheet on a phone.\n */\nexport function DialogSheet({ open, ...rest }: DialogSheetProps) {\n  useSheetPresence(open);\n  return (\n    <RadixDialog.Portal forceMount>\n      <AnimatePresence>{open && <SheetLayer key=\"layer\" {...rest} />}</AnimatePresence>\n    </RadixDialog.Portal>\n  );\n}\n\nfunction SheetLayer({ onDismiss, onOpenAutoFocus, described = true, contentRef, className = \"\", children }: Omit<DialogSheetProps, \"open\">) {\n  const { coarse } = useDevice();\n  const { reduced } = useMotionDial();\n  const enter = useTransition(\"expressive\");\n  const drag = useDismissDrag({ direction: \"down\", onDismiss });\n  const panel = useRef<HTMLDivElement | null>(null);\n  const height = useRef(0);\n  const visible = useVisibleScreen(true);\n\n  const setPanel = useCallback(\n    (node: HTMLDivElement | null) => {\n      panel.current = node;\n      if (typeof contentRef === \"function\") contentRef(node);\n      else if (contentRef) contentRef.current = node;\n    },\n    [contentRef],\n  );\n\n  useLayoutEffect(() => {\n    const el = panel.current;\n    if (!el) return;\n    const read = () => {\n      height.current = el.offsetHeight;\n    };\n    read();\n    if (typeof ResizeObserver === \"undefined\") return;\n    const ro = new ResizeObserver(read);\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n\n  /* The scrim is a function of how far the sheet is down, so it comes in with the rise, lightens as a finger pulls\n     the sheet away, and goes with the exit. While Motion runs its own enter and exit, the value can be a number or\n     a length such as \"100%\", so both are read. */\n  const scrim = useTransform(drag.offset, (y: unknown) => {\n    const h = height.current;\n    const down = typeof y === \"string\" ? (y.endsWith(\"%\") ? (parseFloat(y) / 100) * h : parseFloat(y)) : y;\n    return h > 0 && typeof down === \"number\" && Number.isFinite(down) ? 1 - Math.min(1, Math.max(0, down / h)) : 0;\n  });\n\n  return (\n    /* The layer is the visible screen, so the keyboard lifts the sheet's bottom instead of covering it. */\n    <div\n      className=\"fixed inset-0 z-50 grid grid-rows-[minmax(0,1fr)] items-end\"\n      style={visible ? { top: visible.top, height: visible.height, bottom: \"auto\" } : undefined}\n    >\n      <RadixDialog.Overlay forceMount {...asChildProps}>\n        <motion.div className={SCRIM} style={{ opacity: scrim }} />\n      </RadixDialog.Overlay>\n\n      <RadixDialog.Content\n        forceMount\n        {...asChildProps}\n        ref={setPanel}\n        onOpenAutoFocus={(e) => focusOnOpen(e, panel.current, coarse, onOpenAutoFocus)}\n        {...(described ? {} : { \"aria-describedby\": undefined })}\n      >\n        <motion.div\n          {...drag.panel}\n          onPointerDown={drag.onPointerDown}\n          className={\n            \"litt-glass relative grid max-h-full w-full max-w-[40rem] -mb-6 justify-self-center pb-6 \" +\n            \"grid-cols-[minmax(0,1fr)_auto] grid-rows-[auto_minmax(0,1fr)_auto] rounded-t-litt-surface rounded-b-none \" +\n            FLAT_PANEL + \"[--litt-glass-shadow:var(--litt-shadow-modal)] [--sheet-foot:max(var(--litt-space-6),env(safe-area-inset-bottom,0px))] \" +\n            \"focus-visible:outline-none \" +\n            className\n          }\n          style={{ ...drag.panel.style, gridTemplateAreas: '\"head close\" \"body body\" \"foot foot\"' }}\n          initial={{ y: reduced ? 0 : \"100%\" }}\n          animate={{ y: 0, transition: enter }}\n          exit={{ y: \"100%\", transition: drag.exit }}\n        >\n          <span aria-hidden className=\"pointer-events-none absolute top-2 left-1/2 h-1 w-8 -translate-x-1/2 rounded-litt-pill bg-litt-line-strong\" />\n          {children}\n        </motion.div>\n      </RadixDialog.Content>\n    </div>\n  );\n}\n\n/**\n * The panel: title, description, body and footer, with a dim overlay behind it. Render it inside a Dialog.\n * Focus lands on the control marked data-autofocus, else the first control in the body, else the first one in\n * the footer, and only then on the close button. On a touch screen it lands on the panel, or on a control marked\n * data-autofocus, so the keyboard does not open during the entrance.\n */\nexport function DialogContent({\n  title,\n  description,\n  footer,\n  side = \"center\",\n  tier = \"expressive\",\n  hideClose = false,\n  className = \"\",\n  children,\n}: DialogContentProps) {\n  const { open, change } = useContext(DialogState);\n  const { compact, coarse } = useDevice();\n  const enter = useTransition(tier);\n  const exit = useExit(tier);\n  const fadeIn = useTransition(\"instant\");\n  const fadeOut = useExit(\"instant\");\n  const travel = useTravel();\n  const ref = useRef<HTMLDivElement | null>(null);\n  const sheet = side === \"right\";\n  /* Where the press that opened it was, read as it opens: the panel grows out of it. */\n  const grow = useGrowFrom<HTMLDivElement>(open, !sheet);\n  const grows = grow.grows;\n  /* Under Device the panel switches on and off where it stands (@/motion/deal, the motion languages). */\n  const om = useOverlayMotion();\n  const switches = om.switches && !sheet;\n  const panelRef = (el: HTMLDivElement | null) => {\n    ref.current = el;\n    grow.ref(el);\n  };\n  const hasBody = children != null && children !== false;\n\n  /* Focus the field the person came to fill, or the marked button, else the\n     first control in the footer. Never the close button. */\n  const pickFocus = (e: Event) => {\n    const root = ref.current;\n    if (!root) return;\n    const el =\n      root.querySelector<HTMLElement>(\"[data-autofocus]\") ??\n      root.querySelector<HTMLElement>(`[data-dialog-body] ${FOCUSABLE}`) ??\n      root.querySelector<HTMLElement>(`[data-dialog-footer] ${FOCUSABLE}`);\n    if (el) {\n      e.preventDefault();\n      el.focus({ preventScroll: true });\n    }\n  };\n\n  /* The slots are the same on every screen; compact screens only change their padding and the footer's inset. */\n  const slots = (\n    <>\n      <div className=\"grid content-start gap-1 pt-6 pr-4 pl-6 sm:pt-8 sm:pl-8\" style={{ gridArea: \"head\" }}>\n        <RadixDialog.Title className=\"text-litt-h4 text-litt-fg text-balance\">{title}</RadixDialog.Title>\n        {description && (\n          <RadixDialog.Description className=\"text-litt-small text-litt-fg-subtle\">{description}</RadixDialog.Description>\n        )}\n      </div>\n\n      {hasBody && (\n        <div\n          data-dialog-body\n          className={\"min-h-0 overflow-y-auto px-6 py-6 sm:px-8 \" + (compact ? \"overscroll-contain\" : \"\")}\n          style={{ gridArea: \"body\" }}\n        >\n          {children}\n        </div>\n      )}\n\n      {footer && (\n        <div\n          data-dialog-footer\n          className={\n            \"flex flex-wrap justify-end gap-3 px-6 sm:px-8 \" +\n            (compact\n              ? \"border-t border-litt-line pt-6 pb-(--sheet-foot) in-[[data-material=flat]]:border-transparent\"\n              : sheet\n                ? \"border-t border-litt-line pt-6 pb-[max(1.5rem,env(safe-area-inset-bottom,0px))] in-[[data-material=flat]]:border-transparent\"\n                : \"pb-6 sm:pb-8 \" + (hasBody ? \"pt-2\" : \"pt-6\"))\n          }\n          style={{ gridArea: \"foot\" }}\n        >\n          {footer}\n        </div>\n      )}\n\n      {!hideClose && (\n        <div className=\"self-start pt-6 pr-4 sm:pt-8 sm:pr-6\" style={{ gridArea: \"close\" }}>\n          <RadixDialog.Close {...asChildProps}>\n            <Button\n              iconOnly\n              size=\"sm\"\n              variant=\"ghost\"\n              aria-label=\"Close\"\n              className=\"-mt-1 pointer-coarse:-mt-2.5 pointer-coarse:h-litt-control-md pointer-coarse:w-litt-control-md\"\n            >\n              <XIcon />\n            </Button>\n          </RadixDialog.Close>\n        </div>\n      )}\n    </>\n  );\n\n  if (compact) {\n    return (\n      <DialogSheet\n        open={open}\n        onDismiss={() => change(false)}\n        onOpenAutoFocus={pickFocus}\n        described={!!description}\n        contentRef={ref}\n        className={className}\n      >\n        {slots}\n      </DialogSheet>\n    );\n  }\n\n  return (\n    <RadixDialog.Portal forceMount>\n      <AnimatePresence>\n        {open && (\n          <div\n            key=\"layer\"\n            className={\n              \"fixed inset-0 z-50 grid \" +\n              (sheet ? \"justify-items-end\" : \"place-items-center p-litt-gutter\")\n            }\n          >\n            <RadixDialog.Overlay forceMount {...asChildProps}>\n              <motion.div\n                className={SCRIM}\n                initial={{ opacity: 0 }}\n                animate={{ opacity: 1, transition: fadeIn }}\n                exit={{ opacity: 0, transition: fadeOut }}\n              />\n            </RadixDialog.Overlay>\n\n            <RadixDialog.Content\n              forceMount\n              {...asChildProps}\n              ref={panelRef}\n              onOpenAutoFocus={(e) => focusOnOpen(e, ref.current, coarse, pickFocus)}\n              {...(description ? {} : { \"aria-describedby\": undefined })}\n            >\n              <motion.div\n                className={\n                  \"litt-glass grid grid-cols-[minmax(0,1fr)_auto] grid-rows-[auto_minmax(0,1fr)_auto] [--litt-glass-shadow:var(--litt-shadow-modal)] \" +\n                  FLAT_PANEL +\n                  (sheet\n                    ? \"h-full w-[400px] max-w-[calc(100%-2rem)] rounded-l-litt-surface rounded-r-none \"\n                    : \"max-h-full w-full max-w-[480px] rounded-litt-surface \") +\n                  className\n                }\n                style={{ gridTemplateAreas: '\"head close\" \"body body\" \"foot foot\"' }}\n                initial={sheet ? { opacity: 0, x: travel } : switches ? { opacity: 0 } : grows ? { opacity: 0, scale: GROW } : { opacity: 0, y: travel, scale: 0.98 }}\n                animate={{ opacity: 1, x: 0, y: 0, scale: 1, transition: switches ? om.switchIn : enter }}\n                exit={\n                  sheet\n                    ? { opacity: 0, x: travel / 2, transition: exit }\n                    : switches\n                      ? { opacity: 0, transition: om.switchOut }\n                      : grows\n                      ? { opacity: 0, scale: GROW, transition: exit }\n                      : { opacity: 0, y: travel / 2, scale: 0.98, transition: exit }\n                }\n              >\n                {slots}\n              </motion.div>\n            </RadixDialog.Content>\n          </div>\n        )}\n      </AnimatePresence>\n    </RadixDialog.Portal>\n  );\n}\n",
      "target": "components/litt/dialog.tsx"
    }
  ],
  "docs": "Import Dialog, DialogTrigger, DialogContent and DialogClose from components/litt/dialog. Pair a form's submit with a Cancel, both at size md.",
  "meta": {
    "version": "2026.11.06",
    "released": "2026-11-06",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
