Skip to main content
A dialog is a window overlaid on the primary window or another dialog. Content behind a modal dialog is inert — users cannot interact with it until the dialog is dismissed. It follows the WAI-ARIA Alert and Message Dialogs pattern. Features
  • Supports modal and non-modal modes
  • Focus is trapped and scrolling is blocked in modal mode
  • Screen reader announcements via rendered title and description
  • Pressing Escape closes the dialog
  • Supports controlled and uncontrolled open state
  • Supports nested dialogs with automatic visual hierarchy

Installation

npm install @zag-js/dialog @zag-js/react

Usage

Import the dialog package and connect the machine to your framework:
import * as dialog from "@zag-js/dialog"
import { useMachine, normalizeProps } from "@zag-js/react"
import { useId } from "react"
The dialog package exports two key functions:
  • machine — Behavior logic.
  • connect — Maps behavior to JSX props and event handlers.
import * as dialog from "@zag-js/dialog"
import { useMachine, normalizeProps } from "@zag-js/react"
import { useId } from "react"

export function Dialog() {
  const service = useMachine(dialog.machine, {
    id: useId(),
  })

  const api = dialog.connect(service, normalizeProps)

  return (
    <>
      <button {...api.getTriggerProps()}>Open Dialog</button>

      {api.open && (
        <div {...api.getBackdropProps()} />
      )}

      <div {...api.getPositionerProps()}>
        <div {...api.getContentProps()}>
          <h2 {...api.getTitleProps()}>Edit Profile</h2>
          <p {...api.getDescriptionProps()}>
            Make changes to your profile below. Click save when done.
          </p>
          <button {...api.getCloseTriggerProps()}></button>
          <div>
            {/* dialog body content */}
          </div>
        </div>
      </div>
    </>
  )
}

Managing focus

When the dialog opens, it focuses the first focusable element and keeps keyboard focus inside. When it closes, focus returns to the trigger. To control which element receives focus on open, pass initialFocusEl:
const inputRef = useRef(null)

const service = useMachine(dialog.machine, {
  initialFocusEl: () => inputRef.current,
})
To control which element receives focus on close, pass finalFocusEl:
const buttonRef = useRef(null)

const service = useMachine(dialog.machine, {
  finalFocusEl: () => buttonRef.current,
})

Non-modal dialog

Set modal to false to allow interaction with content behind the dialog. Focus is not trapped and scrolling is not blocked.
const service = useMachine(dialog.machine, {
  modal: false,
})

Closing on outside interaction

By default, the dialog closes when you click its backdrop. Set closeOnInteractOutside to false to prevent this.
const service = useMachine(dialog.machine, {
  closeOnInteractOutside: false,
})
You can also conditionally prevent closing by calling event.preventDefault() in onInteractOutside:
const service = useMachine(dialog.machine, {
  onInteractOutside(event) {
    const target = event.target
    if (target?.closest("[data-outside-exclude]")) {
      event.preventDefault()
    }
  },
})

Controlled dialog

Use open and onOpenChange to control the dialog’s open state externally.
const [open, setOpen] = useState(false)

const service = useMachine(dialog.machine, {
  open,
  onOpenChange(details) {
    // details => { open: boolean }
    setOpen(details.open)
  },
})

Preventing scroll

By default, scrolling on the body is blocked when the dialog is open. Set preventScroll to false to disable this.
const service = useMachine(dialog.machine, {
  preventScroll: false,
})

Alert dialog

Set role to "alertdialog" for dialogs that require an immediate user response.
const service = useMachine(dialog.machine, {
  role: "alertdialog",
})
Alert dialogs should contain two or more action buttons. Use initialFocusEl to set focus to the least destructive action.

Disabling Escape key

Set closeOnEscape to false if the dialog should not close when Escape is pressed.
const service = useMachine(dialog.machine, {
  closeOnEscape: false,
})

Labeling without a visible title

If you do not render a title element, provide an aria-label instead.
const service = useMachine(dialog.machine, {
  "aria-label": "Delete project",
})

API Reference

open
boolean
The controlled open state of the dialog.
defaultOpen
boolean
default:"false"
The initial open state when rendered. Use when you don’t need to control the open state.
modal
boolean
default:"true"
Whether to prevent pointer interaction outside the dialog and hide all content below it.
role
"dialog" | "alertdialog"
default:"\"dialog\""
The ARIA role for the dialog. Use "alertdialog" for urgent, confirmation-required dialogs.
trapFocus
boolean
default:"true"
Whether to trap keyboard focus inside the dialog when open.
preventScroll
boolean
default:"true"
Whether to prevent scrolling behind the dialog when open.
closeOnInteractOutside
boolean
default:"true"
Whether to close the dialog when clicking outside its content.
closeOnEscape
boolean
default:"true"
Whether to close the dialog when the Escape key is pressed.
initialFocusEl
() => HTMLElement | null
Element to receive focus when the dialog opens.
finalFocusEl
() => HTMLElement | null
Element to receive focus when the dialog closes.
restoreFocus
boolean
Whether to restore focus to the element that had focus before the dialog opened.
aria-label
string
Accessible label for the dialog when no visible title is rendered.
onOpenChange
(details: { open: boolean }) => void
Callback invoked when the dialog’s open state changes.

Styling

Each part includes a data-part attribute you can target in CSS.

Parts

PartDescription
triggerThe button that opens the dialog
backdropThe backdrop/overlay behind the dialog
positionerCenters or positions the dialog content
contentThe dialog panel
titleThe dialog title
descriptionThe dialog description
close-triggerThe close button inside the dialog

Open and closed state

[data-part="content"][data-state="open"] {
  /* styles for open state */
}

[data-part="content"][data-state="closed"] {
  /* styles for closed state */
}

[data-part="trigger"][data-state="open"] {
  /* styles when the dialog is open */
}

Nested dialogs

When dialogs are nested, the layer stack automatically applies data attributes for visual hierarchy:
/* Scale down parent dialogs when they have nested children */
[data-part="content"][data-has-nested] {
  transform: scale(calc(1 - var(--nested-layer-count) * 0.05));
  transition: transform 0.2s ease-in-out;
}

/* Style nested dialogs differently */
[data-part="content"][data-nested] {
  border: 2px solid var(--accent-color);
}

/* Deepen backdrop opacity as nesting increases */
[data-part="backdrop"][data-has-nested] {
  opacity: calc(0.4 + var(--nested-layer-count) * 0.1);
}
AttributeDescription
data-nestedApplied to dialogs that are nested inside another dialog
data-has-nestedApplied to dialogs that have a nested dialog open
--nested-layer-countCSS variable with the count of nested dialogs

Accessibility

The dialog follows the WAI-ARIA Dialog pattern and Alert Dialog pattern.
  • The content element has role="dialog" (or role="alertdialog") with aria-modal="true".
  • aria-labelledby points to the title element, and aria-describedby points to the description element.
  • Focus is trapped within the dialog in modal mode.

Keyboard interactions

KeyDescription
EscapeCloses the dialog (unless closeOnEscape is false).
TabMoves focus to the next focusable element within the dialog.
Shift + TabMoves focus to the previous focusable element within the dialog.

Build docs developers (and LLMs) love