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.

PropTypeDescription
asChildbooleanRender 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.

PropTypeDescription
forceMounttrueKeep mounted regardless of open state
asChildbooleanRender 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.

PropTypeDescription
forceMounttrueKeep mounted regardless of open state
onOpenAutoFocus(event: Event) => voidCalled when focus moves into content on open. Call event.preventDefault() to prevent auto-focus.
onCloseAutoFocus(event: Event) => voidCalled when focus moves back to trigger on close
onPointerDownOutside(event) => voidCalled when clicking outside. Prevent default to block close.
onFocusOutside(event) => voidCalled when focus moves outside
onInteractOutside(event) => voidCalled for any outside interaction
onEscapeKeyDown(event) => voidCalled 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.

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.

PropTypeDescription
asChildbooleanRender 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