Dialog
A modal window that traps focus, locks body scroll, returns focus on close, and closes on Escape or overlay click.
Demo
Anatomy
import { Dialog } from "@spacing-ui/core";
<Dialog>
<Dialog.Trigger />
<Dialog.Overlay>
<Dialog.Content>
<Dialog.Title />
<Dialog.Description />
<Dialog.Close />
</Dialog.Content>
</Dialog.Overlay>
</Dialog>API
Dialog (Root)
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | — | Controlled open state |
defaultOpen | boolean | false | Uncontrolled initial state |
onOpenChange | (open: boolean) => void | — | Fires on every open/close |
modal | boolean | true | Whether to lock body scroll and trap focus |
Dialog.Content
| Prop | Type | Default | Description |
|---|---|---|---|
forceMount | boolean | false | Keep mounted when closed (for animations) |
onEscapeKeyDown | (e: KeyboardEvent) => void | — | Call preventDefault() to prevent close |
Accessibility
role="dialog"witharia-modal="true"when modalaria-labelledbywired toDialog.Titleautomatically when presentaria-describedbywired toDialog.Descriptionautomatically when present- Escape closes the dialog
- Tab and Shift+Tab cycle focus within the dialog
- Focus returns to the trigger on close
Common pitfalls
- Overlay click closes the dialog. If your
Dialog.Contentdoesn’t stop propagation, a click inside the content bubbles up toDialog.Overlayand closes the dialog.Dialog.Contenthandles this for you; only override if you know why. - Nested scroll containers. Body scroll lock uses
overflow: hiddenon<html>and pads the scrollbar. If you nest another scroll container inside, its scroll is preserved. - Portals during SSR. Content renders to a portal on mount. The initial server HTML is empty inside the portal; hydrate before probing dialog contents from tests.
Animating open and close
Use forceMount and data-state attribute selectors to keep the dialog in the tree during exit animations:
<Dialog.Content forceMount className="dialog-content">
...
</Dialog.Content>.dialog-content[data-state="open"] { animation: fade-in 150ms ease-out; }
.dialog-content[data-state="closed"] { animation: fade-out 100ms ease-in; }About the focus trap
Focus cycles through every tabbable element inside Dialog.Content in order. If your dialog has no focusable elements, focus lands on Dialog.Content itself (which receives tabindex="-1" automatically) so Escape still works.
Nested dialogs
Nesting is supported. The scroll lock stacks — the body remains locked until the outermost dialog closes — and Escape closes only the topmost dialog.
Testing
await userEvent.click(screen.getByRole("button", { name: /open/i }));
const dialog = await screen.findByRole("dialog");
expect(dialog).toHaveAttribute("aria-modal", "true");
await userEvent.keyboard("{Escape}");
expect(screen.queryByRole("dialog")).toBeNull();Last updated on