Skip to main content

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.

Loading example

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-*).

Loading example

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.

Loading example

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.

Loading example

Props

DialogProps extends Headless UI DialogProps (role, …) with:

PropTypeDefaultDescription
openbooleanControlled visibility; use defaultOpen for uncontrolled
defaultOpenbooleanfalseUncontrolled initial visibility
onOpenChange(open: boolean) => voidCalled 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
dismissiblebooleantruefalse ignores Escape and backdrop clicks
initialFocusRefObject<HTMLElement>Element to focus on open; overrides autoFocus
autoFocusbooleantrueFocus the data-autofocus element, else the dialog root; false focuses the first focusable element
role'dialog' | 'alertdialog''dialog'
classNamestringMerged onto the root (fixed, full-viewport layer)
containerClassNamestringMerged onto the centring layer
backdropClassNamestringMerged onto the backdrop
childrenReactNodeA Dialog.Panel
trackstringNames 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. className merges after the size and scroll classes.
  • Dialog.Title: <h2> by default, polymorphic through as (as="h1" for a full-screen dialog). Wired to aria-labelledby.
  • Dialog.Description: wired to aria-describedby.
  • Dialog.Header, Dialog.Body, Dialog.Footer: layout; Body is the scroll region with inside; Footer stacks below sm and right-aligns in DOM order above it, so put the primary action last.
  • Dialog.Close: label? (default 'Close', from Labels.close), children?, plus Headless UI Button props. 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 when dismissible is false; onClick runs first and preventDefault cancels the close.

Slots

SlotElementNotes
[data-slot="dialog"]divRoot. Also data-size, data-scroll-behavior, data-dismissible, plus Headless UI's data-open / data-closed
[data-slot="dialog-backdrop"]divbg-foreground/50, fades
[data-slot="dialog-container"]divFixed layer that centres the panel; scrolls with outside
[data-slot="dialog-panel"]divAlso data-size
[data-slot="dialog-header"]div
[data-slot="dialog-title"]h2 (or as)
[data-slot="dialog-description"]p
[data-slot="dialog-body"]divBleeds 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"]svgPresent on the icon-only close

Tokens

TokenDefaultUsed for
--dialog-width-sm/md/lg/xl24rem / 32rem / 42rem / 56remPanel max-width per size (fallbacks; define the token to retheme)
--dialog-paddingcalc(var(--spacing) * 6)Panel padding and the body's bleed; override this, not p-*
--popover, --popover-foreground, --borderPanel surface
--radius-lg, --shadow-4Panel corners and shadow
--duration-normalBackdrop and panel transitions

Accessibility

  • Root is role="dialog" with aria-modal="true"; pass role="alertdialog" for confirmations. aria-labelledby and aria-describedby point at Dialog.Title / Dialog.Description automatically.
  • Focus moves into the dialog on open (the data-autofocus element, else the root so nothing looks pressed), is trapped with Tab / Shift+Tab, and returns to the opener on close. Everything outside is inert and the page is scroll-locked.
  • Escape closes while dismissible. A non-dismissible dialog must offer a visible way out: keep a Dialog.Close inside the panel.
  • The icon-only Dialog.Close has a visually hidden label ("Close", translatable through LabelsProvider).
  • 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.
  • Drawer: the same contract sliding in from an edge.
  • Popover: non-modal, anchored to a trigger.
  • Button: the trigger and the footer actions.