Dialog
A dialog is a window overlaid on either the primary window or another dialog window. Content behind a modal dialog is inert, meaning that users cannot interact with it.
Features
- Supports modal and non-modal modes
- Focus is trapped and scrolling is blocked in the modal mode
- Provides screen reader announcements via rendered title and description
- Pressing
Esccloses the dialog
Installation
Install the dialog package:
npm install @zag-js/dialog @zag-js/react # or yarn add @zag-js/dialog @zag-js/react
npm install @zag-js/dialog @zag-js/solid # or yarn add @zag-js/dialog @zag-js/solid
npm install @zag-js/dialog @zag-js/vue # or yarn add @zag-js/dialog @zag-js/vue
npm install @zag-js/dialog @zag-js/svelte # or yarn add @zag-js/dialog @zag-js/svelte
Anatomy
To use the dialog component correctly, you'll need to understand its anatomy and how we name its parts.
Each part includes a
data-partattribute to help identify them in the DOM.
Usage
Import the dialog package:
import * as dialog from "@zag-js/dialog"
The dialog package exports two key functions:
machine- Behavior logic for the dialog.connect- Maps behavior to JSX props and event handlers.
Pass a unique
idtouseMachineso generated element ids stay predictable.
Then use the framework integration helpers:
import * as dialog from "@zag-js/dialog" import { useMachine, normalizeProps, Portal } from "@zag-js/react" export function Dialog() { const service = useMachine(dialog.machine, { id: "1" }) const api = dialog.connect(service, normalizeProps) return ( <> <button {...api.getTriggerProps()}>Open Dialog</button> {api.open && ( <Portal> <div {...api.getBackdropProps()} /> <div {...api.getPositionerProps()}> <div {...api.getContentProps()}> <h2 {...api.getTitleProps()}>Edit profile</h2> <p {...api.getDescriptionProps()}> Make changes to your profile here. Click save when you are done. </p> <div> <input placeholder="Enter name..." /> <button>Save</button> </div> <button {...api.getCloseTriggerProps()}>Close</button> </div> </div> </Portal> )} </> ) }
import * as dialog from "@zag-js/dialog" import { Portal } from "solid-js/web" import { useMachine, normalizeProps } from "@zag-js/solid" import { createMemo, createUniqueId, Show } from "solid-js" export default function Page() { const service = useMachine(dialog.machine, { id: createUniqueId() }) const api = createMemo(() => dialog.connect(service, normalizeProps)) return ( <> <button {...api().getTriggerProps()}>Open Dialog</button> <Show when={api().open}> <Portal> <div {...api().getBackdropProps()} /> <div {...api().getPositionerProps()}> <div {...api().getContentProps()}> <h2 {...api().getTitleProps()}>Edit profile</h2> <p {...api().getDescriptionProps()}> Make changes to your profile here. Click save when you are done. </p> <button {...api().getCloseTriggerProps()}>X</button> <input placeholder="Enter name..." /> <button>Save Changes</button> </div> </div> </Portal> </Show> </> ) }
<script setup> import * as dialog from "@zag-js/dialog" import { normalizeProps, useMachine } from "@zag-js/vue" import { computed, Teleport } from "vue" const service = useMachine(dialog.machine, { id: "1" }) const api = computed(() => dialog.connect(service, normalizeProps)) </script> <template> <button ref="ref" v-bind="api.getTriggerProps()">Open Dialog</button> <Teleport to="body"> <div v-if="api.open"> <div v-bind="api.getBackdropProps()" /> <div v-bind="api.getPositionerProps()"> <div v-bind="api.getContentProps()"> <h2 v-bind="api.getTitleProps()">Edit profile</h2> <p v-bind="api.getDescriptionProps()"> Make changes to your profile here. Click save when you are done. </p> <button v-bind="api.getCloseTriggerProps()">X</button> <input placeholder="Enter name..." /> <button>Save Changes</button> </div> </div> </div> </Teleport> </template>
<script lang="ts"> import * as dialog from "@zag-js/dialog" import { portal, normalizeProps, useMachine } from "@zag-js/svelte" const id = $props.id() const service = useMachine(dialog.machine, ({ id })) const api = $derived(dialog.connect(service, normalizeProps)) </script> <button {...api.getTriggerProps()}>Open Dialog</button> {#if api.open} <div use:portal {...api.getBackdropProps()}></div> <div use:portal {...api.getPositionerProps()}> <div {...api.getContentProps()}> <h2 {...api.getTitleProps()}>Edit profile</h2> <p {...api.getDescriptionProps()}>Make changes to your profile here. Click save when you are done.</p> <div> <input placeholder="Enter name..." /> <button>Save</button> </div> <button {...api.getCloseTriggerProps()}>Close</button> </div> </div> {/if}
Managing focus within the dialog
When the dialog opens, it focuses the first focusable element and keeps keyboard focus inside the dialog.
To control what receives focus on open, pass initialFocusEl.
export function Dialog() { // initial focused element ref const inputRef = useRef(null) const service = useMachine(dialog.machine, { initialFocusEl: () => inputRef.current, }) // ... return ( //... <input ref={inputRef} /> // ... ) }
export function Dialog() { // initial focused element signal const [inputEl, setInputEl] = createSignal() const service = useMachine(dialog.machine, { initialFocusEl: inputEl, }) // ... return ( //... <input ref={setInputEl} /> // ... ) }
<script setup> import { ref } from "vue" // initial focused element ref const inputRef = ref(null) const service = useMachine(dialog.machine, { initialFocusEl: () => inputRef.value, }) </script> <template> <input ref="inputRef" /> </template>
<script lang="ts"> // initial focused element ref let inputRef: HTMLInputElement | null = null const service = useMachine( dialog.machine, ({ initialFocusEl: () => inputRef, }), ) // ... </script> <!-- ... --> <input bind:this={inputRef} /> <!-- ... -->
To control what receives focus when the dialog closes, pass finalFocusEl.
Dialog vs non-modal dialog
Set modal to false to allow interaction with content behind the dialog.
const service = useMachine(dialog.machine, { modal: false, })
Closing the dialog on interaction outside
By default, the dialog closes when you click its overlay. You can set
closeOnInteractOutside to false if you want the modal to stay visible.
const service = useMachine(dialog.machine, { closeOnInteractOutside: false, })
You can also customize the behavior by passing a function to the
onInteractOutside callback and calling event.preventDefault().
const service = useMachine(dialog.machine, { onInteractOutside(event) { const target = event.target if (target?.closest("<selector>")) { return event.preventDefault() } }, })
Listening for open state changes
When the dialog is opened or closed, the onOpenChange callback is invoked.
const service = useMachine(dialog.machine, { onOpenChange(details) { // details => { open: boolean } console.log("open:", details.open) }, })
Closing with Escape
Set closeOnEscape to false if the dialog should not close on Esc.
const service = useMachine(dialog.machine, { closeOnEscape: false, })
Controlled dialog
To control the dialog's open state, pass the open and onOpenChange
properties.
import { useState } from "react" export function ControlledDialog() { const [open, setOpen] = useState(false) const service = useMachine(dialog.machine, { open, onOpenChange(details) { setOpen(details.open) }, }) return ( // ... ) }
import { createSignal } from "solid-js" export function ControlledDialog() { const [open, setOpen] = createSignal(false) const service = useMachine(dialog.machine, { get open() { return open() }, onOpenChange(details) { setOpen(details.open) }, }) return ( // ... ) }
<script lang="ts" setup> import { ref } from "vue" const openRef = ref(false) const service = useMachine(dialog.machine, { get open() { return openRef.value }, onOpenChange(details) { openRef.value = details.open }, }) </script>
<script lang="ts"> let open = $state(false) const service = useMachine(dialog.machine, { get open() { return open }, onOpenChange(details) { open = details.open }, }) </script>
Controlling the scroll behavior
When the dialog is open, it prevents scrolling on the body element. To disable
this behavior, set preventScroll to false.
const service = useMachine(dialog.machine, { preventScroll: false, })
Creating an alert dialog
The dialog supports both dialog and alertdialog roles. It uses dialog by
default. Set role to alertdialog for urgent actions.
That's it! Now you have an alert dialog.
const service = useMachine(dialog.machine, { role: "alertdialog", })
By definition, an alert dialog will contain two or more action buttons. We recommend setting focus to the least destructive action via
initialFocusEl.
Labeling without a visible title
If you do not render a title element, provide aria-label.
const service = useMachine(dialog.machine, { "aria-label": "Delete project", })
Styling guide
Each part includes a data-part attribute you can target in CSS.
[data-part="trigger"] { /* styles for the trigger element */ } [data-part="backdrop"] { /* styles for the backdrop element */ } [data-part="positioner"] { /* styles for the positioner element */ } [data-part="content"] { /* styles for the content element */ } [data-part="title"] { /* styles for the title element */ } [data-part="description"] { /* styles for the description element */ } [data-part="close-trigger"] { /* styles for the close trigger element */ }
Open and closed state
The dialog has two states: open and closed. You can use the data-state
attribute to style the dialog or trigger based on its state.
[data-part="content"][data-state="open|closed"] { /* styles for the open state */ } [data-part="trigger"][data-state="open|closed"] { /* styles for the open state */ }
Nested dialogs
When dialogs are nested (a dialog opened from within another dialog), the layer stack automatically applies data attributes to help create visual hierarchy.
data-nested- Applied to nested dialogsdata-has-nested- Applied to dialogs that have nested dialogs open--nested-layer-count- CSS variable indicating the number of nested dialogs
/* 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); } /* Create depth effect using backdrop opacity */ [data-part="backdrop"][data-has-nested] { opacity: calc(0.4 + var(--nested-layer-count) * 0.1); }
Methods and Properties
Machine Context
The dialog machine exposes the following context properties:
idsPartial<{ trigger: string; positioner: string; backdrop: string; content: string; closeTrigger: string; title: string; description: string; }>The ids of the elements in the dialog. Useful for composition.trapFocusbooleanWhether to trap focus inside the dialog when it's openedpreventScrollbooleanWhether to prevent scrolling behind the dialog when it's openedmodalbooleanWhether to prevent pointer interaction outside the element and hide all content below itinitialFocusEl() => HTMLElementElement to receive focus when the dialog is openedfinalFocusEl() => HTMLElementElement to receive focus when the dialog is closedrestoreFocusbooleanWhether to restore focus to the element that had focus before the dialog was openedcloseOnInteractOutsidebooleanWhether to close the dialog when the outside is clickedcloseOnEscapebooleanWhether to close the dialog when the escape key is pressedaria-labelstringHuman readable label for the dialog, in event the dialog title is not renderedrole"dialog" | "alertdialog"The dialog's roleopenbooleanThe controlled open state of the dialogdefaultOpenbooleanThe initial open state of the dialog when rendered. Use when you don't need to control the open state of the dialog.onOpenChange(details: OpenChangeDetails) => voidFunction to call when the dialog's open state changesdir"ltr" | "rtl"The document's text/writing direction.idstringThe unique identifier of the machine.getRootNode() => Node | ShadowRoot | DocumentA root node to correctly resolve document in custom environments. E.x.: Iframes, Electron.onEscapeKeyDown(event: KeyboardEvent) => voidFunction called when the escape key is pressedonRequestDismiss(event: LayerDismissEvent) => voidFunction called when this layer is closed due to a parent layer being closedonPointerDownOutside(event: PointerDownOutsideEvent) => voidFunction called when the pointer is pressed down outside the componentonFocusOutside(event: FocusOutsideEvent) => voidFunction called when the focus is moved outside the componentonInteractOutside(event: InteractOutsideEvent) => voidFunction called when an interaction happens outside the componentpersistentElements(() => Element)[]Returns the persistent elements that: - should not have pointer-events disabled - should not trigger the dismiss event
Machine API
The dialog api exposes the following methods:
openbooleanWhether the dialog is opensetOpen(open: boolean) => voidFunction to open or close the dialog
Data Attributes
CSS Variables
Accessibility
Adheres to the Alert and Message Dialogs WAI-ARIA design pattern.
Keyboard Interactions
- EnterWhen focus is on the trigger, opens the dialog.
- TabMoves focus to the next focusable element within the content. Focus is trapped within the dialog.
- Shift + TabMoves focus to the previous focusable element. Focus is trapped within the dialog.
- EscCloses the dialog and moves focus to trigger or the defined final focus element