Dialog
A modal surface for a task that stops the page: a form, a choice, a confirmation. The dialog owns the surface, the scrim, the focus trap, the scroll lock, and every exit. The app owns the content, the open state, and the result of the operation, shown inline via alert after close.
Parts
Nine parts: Dialog, DialogTrigger, DialogContent, DialogTitle, DialogDescription, DialogBody, DialogFooter, DialogClose, and the built-in X close button. There is no DialogHeader — DialogContent stacks title, description, body, and footer with a fixed gap; the title-to-description gap is tightened by margin on DialogDescription. DialogTitle is required and set in Baloo 2, because a modal that stops the page must say what it is. DialogDescription is optional. DialogBody is the only scroll region. The X is always rendered, positioned top-right, and last in the DOM.
There is no alert-dialog. A destructive confirm is a plain Dialog with dismissible={false} and a destructive button in the footer — Radix's AlertDialog adds an announcement mode and no keyboard difference, so this system ships one component instead of two.
Sizes
Two sizes, both centered and capped to the viewport minus 32px on every side. default is 448px, wide enough for a confirmation or a short form. lg is 672px, a 1.5 ratio that fits a two-column form. There is no bottom sheet.
Scroll and footer
DialogBody scrolls while the title and footer stay pinned, so a confirm button never scrolls out of reach under a phone keyboard. The footer is justify-end gap-2 from 640px and full-width, column-reversed below it, so the primary action sits under the thumb and last in the DOM for the keyboard.
Dismissible and pending
dismissible={false} blocks an outside click only, permanently — Escape and the X still close. pending blocks Escape, outside click, and the X all at once, disables the X, and sets aria-busy on the surface. The dialog shows no other busyness of its own; the button inside carries the spinner.
Live example
Open the dialog to see the enter and exit motion, the focus trap, and the disabled X. Save sets pending on the dialog while the button carries its own spinner.
One dialog, many triggers
An app composes its own TripDialog around Dialog and DialogContent, taking the trigger as children and forwarding open and onOpenChange optionally. hottrip reuses one TripDialog behind a toolbar button and a menu item below by passing no trigger at all — the trigger-less controlled form — and driving open from its own state. Only a real DialogTrigger sets aria-haspopup, aria-expanded, and aria-controls on its Button. A button driving the trigger-less form, like the two below, gets none of that wiring — it is a plain button that happens to open a dialog.
Motion
Enter scales from 0.98 plus a fade over 250ms on the bounce curve; exit runs 150ms on the settle curve. The overlay animates opacity only, from 0 to 0.5 over a solid neutral-950, so no color in the system carries alpha.
Accessibility
The dialog is labelled by its required title and, when present, described by DialogDescription. Focus lands inside on open — on the first body or footer control, never the X — and returns to the trigger on close. Tab cycles inside and ends on the X. Content outside an open dialog is hidden from the accessibility tree. Every text pair meets WCAG AA in both modes — the tightest is the light description at 4.53:1 against its 4.5:1 floor, measured by converting each OKLCH color to sRGB and computing the WCAG ratio directly, not estimated.