Skip to main content

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.

PropTypeDefaultDescription
openboolean-Controlled open state
defaultOpenbooleanfalseInitial open state (uncontrolled)
onOpenChange(open: boolean) => void-Called when the open state should change
modalbooleantrueWhen 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.

PropTypeDefaultDescription
asChildboolean-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.

PropTypeDefaultDescription
containerElement | nulldocument.bodyPortal target
forceMounttrue-Force mount content (bypasses Presence)

Dialog.Overlay

Renders an overlay behind the content. Only renders when modal is true. Automatically locks body scroll.

PropTypeDefaultDescription
forceMounttrue-Keep mounted regardless of open state
asChildboolean-Render as child element
lockScrollbooleancontext.openOverride whether body scroll is locked

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.

PropTypeDefaultDescription
forceMounttrue-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.
trapFocusbooleancontext.openOverride whether focus is trapped inside the content
disableOutsidePointerEventsbooleancontext.openOverride whether pointer events outside are blocked

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.

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.

PropTypeDefaultDescription
asChildboolean-Render as child element

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; }
}

Keyboard interactions

KeyAction
Space / EnterOpens the dialog (on Trigger)
TabCycles through focusable elements inside content
Shift+TabCycles backward through focusable elements
EscapeCloses the dialog