Skip to Content

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)

PropTypeDefaultDescription
openbooleanControlled open state
defaultOpenbooleanfalseUncontrolled initial state
onOpenChange(open: boolean) => voidFires on every open/close
modalbooleantrueWhether to lock body scroll and trap focus

Dialog.Content

PropTypeDefaultDescription
forceMountbooleanfalseKeep mounted when closed (for animations)
onEscapeKeyDown(e: KeyboardEvent) => voidCall preventDefault() to prevent close

Accessibility

  • role="dialog" with aria-modal="true" when modal
  • aria-labelledby wired to Dialog.Title automatically when present
  • aria-describedby wired to Dialog.Description automatically 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.Content doesn’t stop propagation, a click inside the content bubbles up to Dialog.Overlay and closes the dialog. Dialog.Content handles this for you; only override if you know why.
  • Nested scroll containers. Body scroll lock uses overflow: hidden on <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