Dialog
A modal or non-modal dialog with focus trapping, scroll locking, and accessible labeling.
import * as Dialog from '@gentleduck/primitives/dialog'import * as Dialog from '@gentleduck/primitives/dialog'Anatomy
<Dialog.Root>
<Dialog.Trigger />
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content>
<Dialog.Title />
<Dialog.Description />
<Dialog.Close />
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root><Dialog.Root>
<Dialog.Trigger />
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content>
<Dialog.Title />
<Dialog.Description />
<Dialog.Close />
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>Example
import * as Dialog from '@gentleduck/primitives/dialog'
function DeleteConfirmation() {
return (
<Dialog.Root>
<Dialog.Trigger className="px-4 py-2 bg-red-500 text-white rounded">
Delete account
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 animate-fadeIn" />
<Dialog.Content className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white p-6 rounded-lg shadow-xl max-w-md w-full animate-scaleIn">
<Dialog.Title className="text-lg font-semibold">
Delete your account?
</Dialog.Title>
<Dialog.Description className="mt-2 text-gray-600">
This will permanently delete your account and all associated data.
</Dialog.Description>
<div className="mt-4 flex gap-2 justify-end">
<Dialog.Close className="px-4 py-2 border rounded">
Cancel
</Dialog.Close>
<button className="px-4 py-2 bg-red-500 text-white rounded">
Delete
</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}import * as Dialog from '@gentleduck/primitives/dialog'
function DeleteConfirmation() {
return (
<Dialog.Root>
<Dialog.Trigger className="px-4 py-2 bg-red-500 text-white rounded">
Delete account
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 animate-fadeIn" />
<Dialog.Content className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white p-6 rounded-lg shadow-xl max-w-md w-full animate-scaleIn">
<Dialog.Title className="text-lg font-semibold">
Delete your account?
</Dialog.Title>
<Dialog.Description className="mt-2 text-gray-600">
This will permanently delete your account and all associated data.
</Dialog.Description>
<div className="mt-4 flex gap-2 justify-end">
<Dialog.Close className="px-4 py-2 border rounded">
Cancel
</Dialog.Close>
<button className="px-4 py-2 bg-red-500 text-white rounded">
Delete
</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}API
Dialog.Root
The root component that manages open/closed state and provides context to all children.
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | -- | Controlled open state |
defaultOpen | boolean | false | Initial open state (uncontrolled) |
onOpenChange | (open: boolean) => void | -- | Called when the open state should change |
modal | boolean | true | When true, enables focus trapping, scroll lock, and hides other content from screen readers |
dir | 'ltr' | 'rtl' | -- | Reading direction for keyboard navigation |
Dialog.Trigger
Button that toggles the dialog. Renders a <button> by default.
| Prop | Type | Description |
|---|---|---|
asChild | boolean | Render as the child element instead of a <button> |
Sets aria-haspopup="dialog", aria-expanded, aria-controls, and data-state automatically.
Dialog.Portal
Renders children into document.body (or a custom container) via React portal.
| Prop | Type | Default | Description |
|---|---|---|---|
container | Element | null | document.body | Portal target |
forceMount | true | -- | Force mount content (bypasses Presence) |
Dialog.Overlay
Renders an overlay behind the content. Only renders when modal is true. Automatically locks body scroll.
| Prop | Type | Description |
|---|---|---|
forceMount | true | Keep mounted regardless of open state |
asChild | boolean | Render as child element |
Exposes data-state="open" / data-state="closed" for CSS animation.
Dialog.Content
The content area. Handles focus trapping (modal), dismiss-on-click-outside, and escape-to-close.
| Prop | Type | Description |
|---|---|---|
forceMount | true | Keep mounted regardless of open state |
onOpenAutoFocus | (event: Event) => void | Called when focus moves into content on open. Call event.preventDefault() to prevent auto-focus. |
onCloseAutoFocus | (event: Event) => void | Called when focus moves back to trigger on close |
onPointerDownOutside | (event) => void | Called when clicking outside. Prevent default to block close. |
onFocusOutside | (event) => void | Called when focus moves outside |
onInteractOutside | (event) => void | Called for any outside interaction |
onEscapeKeyDown | (event) => void | Called when Escape is pressed. Prevent default to block close. |
Sets role="dialog", aria-labelledby (linked to Title), and aria-describedby (linked to Description).
Dialog.Title
Accessible title. Renders an <h2> by default. Connected to Content via aria-labelledby.
Always include a Title for accessibility. In development, a console warning appears if no Title is found.
Dialog.Description
Accessible description. Renders a <p> by default. Connected to Content via aria-describedby.
Dialog.Close
Button that closes the dialog. Renders a <button> by default.
| Prop | Type | Description |
|---|---|---|
asChild | boolean | Render as child element |
Modal vs non-modal
When modal={true} (default):
- Focus is trapped inside the content.
- Body scroll is locked.
- Other content is hidden from screen readers via
aria-hidden. - Clicking outside dismisses the dialog.
Animation
Use data-state for CSS animations:
.dialog-overlay[data-state="open"] {
animation: fadeIn 200ms ease;
}
.dialog-overlay[data-state="closed"] {
animation: fadeOut 200ms ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}.dialog-overlay[data-state="open"] {
animation: fadeIn 200ms ease;
}
.dialog-overlay[data-state="closed"] {
animation: fadeOut 200ms ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}The Presence system detects these animations and delays unmounting until they complete.
Keyboard interactions
| Key | Action |
|---|---|
| Space / Enter | Opens the dialog (on Trigger) |
| Tab | Cycles through focusable elements inside content |
| Shift+Tab | Cycles backward through focusable elements |
| Escape | Closes the dialog |