Dialog
A modal that takes over the page: Headless UI's Dialog portalled to
<body>, focus trapped in the panel, the rest of the page inert and
scroll-locked, focus restored on close. The panel is the bg-popover
surface with shadow-4; backdrop and panel fade in and out on
data-closed.
import Dialog from '@zuilib/primitives/dialog'
Basic
open and onOpenChange are the whole contract: a controlled dialog never
closes on its own, it asks through onOpenChange(false) (Escape, backdrop
click, Dialog.Close) and you flip open. Uncontrolled, start it with
defaultOpen and it closes itself.
Sizes
sm to xl cap the panel width with --dialog-width-* (24 / 32 / 42 /
56rem fallbacks). full fills the viewport: no margin, no rounding.
Below sm (40rem) every size is the viewport minus a 1rem gutter, with
Dialog.Body scrolling; full also pads past env(safe-area-inset-*).
Scrolling
scrollBehavior="inside" (default) caps the panel at the viewport and
scrolls Dialog.Body, so the header and footer stay put. outside lets
the panel grow and scrolls the page behind the backdrop.
Non-dismissible and initial focus
dismissible={false} ignores Escape and backdrop clicks; only
Dialog.Close (or the parent flipping open) closes it. Mark the element to
focus on open with autoFocus on any Headless UI button, or pass
initialFocus.
Props
DialogProps extends Headless UI DialogProps (role, …) with:
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | — | Controlled visibility; use defaultOpen for uncontrolled |
defaultOpen | boolean | false | Uncontrolled initial visibility |
onOpenChange | (open: boolean) => void | — | Called with false on Escape and backdrop click (while dismissible) and Dialog.Close. A controlled dialog never closes on its own; flip open in response |
size | 'sm' | 'md' | 'lg' | 'xl' | 'full' | 'md' | Panel max-width from --dialog-width-*; full fills the viewport |
scrollBehavior | 'inside' | 'outside' | 'inside' | inside scrolls Dialog.Body; outside scrolls the viewport |
dismissible | boolean | true | false ignores Escape and backdrop clicks |
initialFocus | RefObject<HTMLElement> | — | Element to focus on open; overrides autoFocus |
autoFocus | boolean | true | Focus the data-autofocus element, else the dialog root; false focuses the first focusable element |
role | 'dialog' | 'alertdialog' | 'dialog' | |
className | string | — | Merged onto the root (fixed, full-viewport layer) |
containerClassName | string | — | Merged onto the centring layer |
backdropClassName | string | — | Merged onto the backdrop |
children | ReactNode | — | A Dialog.Panel |
track | string | — | Names the open / close telemetry events (emitted whenever a TelemetryProvider is present) |
Parts
Dialog.Panel: the surface; clicks outside it count as backdrop clicks, so keep everything inside.classNamemerges after the size and scroll classes.Dialog.Title:<h2>by default, polymorphic throughas(as="h1"for a full-screen dialog). Wired toaria-labelledby.Dialog.Description: wired toaria-describedby.Dialog.Header,Dialog.Body,Dialog.Footer: layout;Bodyis the scroll region withinside;Footerstacks belowsmand right-aligns in DOM order above it, so put the primary action last.Dialog.Close:label?(default'Close', fromLabels.close),children?, plus Headless UIButtonprops. No children: an X icon button pinned top-right. With children: a plain button.as={Fragment}hands the close behaviour to the single child. Works even whendismissibleisfalse;onClickruns first andpreventDefaultcancels the close.
Slots
| Slot | Element | Notes |
|---|---|---|
[data-slot="dialog"] | div | Root. Also data-size, data-scroll-behavior, data-dismissible, plus Headless UI's data-open / data-closed |
[data-slot="dialog-backdrop"] | div | bg-foreground/50, fades |
[data-slot="dialog-container"] | div | Fixed layer that centres the panel; scrolls with outside |
[data-slot="dialog-panel"] | div | Also data-size |
[data-slot="dialog-header"] | div | — |
[data-slot="dialog-title"] | h2 (or as) | — |
[data-slot="dialog-description"] | p | — |
[data-slot="dialog-body"] | div | Bleeds through the panel padding so its scrollbar sits at the edge |
[data-slot="dialog-footer"] | div | — |
[data-slot="dialog-close"] | button (or as) | — |
[data-slot="dialog-close-icon"] | svg | Present on the icon-only close |
Tokens
| Token | Default | Used for |
|---|---|---|
--dialog-width-sm/md/lg/xl | 24rem / 32rem / 42rem / 56rem | Panel max-width per size (fallbacks; define the token to retheme) |
--dialog-padding | calc(var(--spacing) * 6) | Panel padding and the body's bleed; override this, not p-* |
--popover, --popover-foreground, --border | Panel surface | |
--radius-lg, --shadow-4 | Panel corners and shadow | |
--duration-normal | Backdrop and panel transitions |
Accessibility
- Root is
role="dialog"witharia-modal="true"; passrole="alertdialog"for confirmations.aria-labelledbyandaria-describedbypoint atDialog.Title/Dialog.Descriptionautomatically. - Focus moves into the dialog on open (the
data-autofocuselement, else the root so nothing looks pressed), is trapped with Tab / Shift+Tab, and returns to the opener on close. Everything outside isinertand the page is scroll-locked. - Escape closes while
dismissible. A non-dismissible dialog must offer a visible way out: keep aDialog.Closeinside the panel. - The icon-only
Dialog.Closehas a visually hiddenlabel("Close", translatable throughLabelsProvider). - The root carries
print:hidden, so an open dialog never prints. - Transitions honour
prefers-reduced-motion. - On the server only Headless UI's portal sentinel renders; the dialog mounts on the client.