Dialog
Accessible modal dialog using native <dialog> with showModal(). Browser-managed focus trap, top layer, and Escape handling.
npx artui@latest add dialogPlayground
Try Esc, Tab, and clicking the backdrop.
The dialog now uses the shared artui tokens: a neutral surface, solid border, and the lighter --artui-shadow-lg elevation.
Usage
Basic
import { useState } from 'react';
import { Dialog } from '@/components/dialog';
const [open, setOpen] = useState(false);
<button onClick={() => setOpen(true)}>Open dialog</button>
<Dialog open={open} onClose={() => setOpen(false)} title="Confirm deletion">
<p>This action cannot be undone.</p>
<button onClick={() => setOpen(false)}>Cancel</button>
<button onClick={handleConfirm}>Delete</button>
</Dialog>The title prop renders a visible <h2> and wires aria-labelledby. Every Dialog needs exactly one of title or aria-labelledby.
With description
<Dialog
open={open}
onClose={() => setOpen(false)}
title="Delete account"
description="Your data will be permanently removed after 30 days."
>
<button onClick={() => setOpen(false)}>Cancel</button>
<button onClick={handleDelete}>Delete</button>
</Dialog>The description renders a <p> and wires aria-describedby.
Initial focus
const cancelRef = useRef<HTMLButtonElement>(null);
<Dialog
open={open}
onClose={() => setOpen(false)}
title="Unsaved changes"
initialFocusRef={cancelRef}
>
<button ref={cancelRef} onClick={() => setOpen(false)}>Discard changes</button>
<button onClick={handleSave}>Save</button>
</Dialog>By default focus lands on the first focusable element. Pass initialFocusRef to point at the safer action.
External label
<h2 id="dialog-heading">Account settings</h2>
<Dialog open={open} onClose={() => setOpen(false)} aria-labelledby="dialog-heading">
<p>Manage your account details.</p>
</Dialog>A dev overlay fires if the referenced id does not exist in the DOM.
No backdrop dismiss
<Dialog
open={open}
onClose={() => setOpen(false)}
title="Required confirmation"
closeOnBackdropClick={false}
>
<p>Please confirm before continuing.</p>
<button onClick={() => setOpen(false)}>OK</button>
</Dialog>Escape always works regardless of this prop.
API
| Prop | Type | Default | Description |
|---|---|---|---|
| title | AccessibleText | none | Visible dialog heading rendered as an <h2>. Exactly one of title or aria-labelledby is required: passing neither or both is a compile error. |
| aria-labelledby | string | none | ID of an existing element that labels this dialog. Mutually exclusive with title. |
| openrequired | boolean | none | Controls whether the dialog is shown. |
| onCloserequired | () => void | none | Called when the user closes the dialog (close button, Escape, or backdrop click). The consumer is responsible for setting open to false. |
| childrenrequired | ReactNode | none | Dialog body content. Rendering no content triggers a dev overlay. |
| description | AccessibleText | none | Optional supplementary description rendered as a <p> and wired to the dialog via aria-describedby. |
| initialFocusRef | RefObject<HTMLElement | null> | none | Ref to the element that should receive focus when the dialog opens. Falls back to the first focusable descendant. |
| returnFocusRef | RefObject<HTMLElement | null> | none | Ref to the element that should receive focus when the dialog closes. Falls back to the element that was focused before the dialog opened. |
| closeOnBackdropClick | boolean | true | Whether clicking the backdrop calls onClose. |
| className | string | none | Additional CSS class applied to the <dialog> element. |
| id | string | none | Applied to the <dialog> element. Pass the same value to DialogTrigger's controls prop so aria-controls resolves correctly. |
Keyboard
| Key | Action |
|---|---|
| Tab | Move focus to next focusable element inside the dialog |
| Shift+Tab | Move focus to previous focusable element |
| Escape | Close the dialog and restore focus |
The browser constrains focus to the dialog. After close, focus returns to the element that was active before the dialog opened (or returnFocusRef).
Accessibility
| Criterion | Name | How the component satisfies it |
|---|---|---|
| 1.3.1(A) | Info and Relationships | The title prop renders a semantic <h2> inside the dialog. A dev overlay fires when children is empty so the developer sees the issue immediately. |
| 2.1.1(A) | Keyboard | The header close button is always rendered, so the dialog never has zero focus stops. A dev overlay fires when the dialog body has no focusable elements, prompting the developer to add interactive content. |
| 2.1.2(A) | No Keyboard Trap | Focus is trapped inside the dialog by the browser's native showModal() implementation. Escape always exits. |
| 2.4.3(A) | Focus Order | Focus moves to initialFocusRef (or the first focusable descendant) on open, and returns to the previously focused element (or returnFocusRef) on close. |
| 2.4.7(AA) | Focus Visible | The close button uses outline (not box-shadow) for its focus ring, preserving visibility in Windows High Contrast Mode. |
| 4.1.2(A) | Name, Role, Value | The <dialog> element has role="dialog" natively, aria-modal="true", and aria-labelledby pointing at either the auto-generated title <h2> or a caller-supplied element. A dev overlay fires when aria-labelledby does not resolve to a DOM element. |
| 4.1.2(A) | Name, Role, Value | DialogTrigger hard-wires aria-haspopup="dialog", aria-expanded (driven by the required open prop), and aria-controls (pointing at the Dialog's id). A dev console.error fires when controls does not resolve to a DOM element. |
Do
Provide a title or aria-labelledby
<Dialog open={open} onClose={() => setOpen(false)} title="Confirm deletion">
<p>This action cannot be undone.</p>
<button onClick={() => setOpen(false)}>Cancel</button>
</Dialog>Include at least one interactive element in children
<Dialog open={open} onClose={() => setOpen(false)} title="Notice">
<p>Your session will expire in 5 minutes.</p>
<button onClick={() => setOpen(false)}>Dismiss</button>
</Dialog>Use initialFocusRef to land focus on the safe action
const cancelRef = useRef<HTMLButtonElement>(null);
<Dialog open={open} onClose={() => setOpen(false)} title="Delete account" initialFocusRef={cancelRef}>
<button ref={cancelRef} onClick={() => setOpen(false)}>Cancel</button>
<button onClick={handleDelete}>Delete</button>
</Dialog>Don't
Omit both title and aria-labelledby
<Dialog open={open} onClose={() => setOpen(false)}> {/* compile error */}
<p>Content</p>
</Dialog>Omitting both label sources is a TypeScript compile error.
Render a dialog with no interactive body content
<Dialog open={open} onClose={() => setOpen(false)} title="Notice">
<p>Your session will expire in 5 minutes.</p>
{/* No button: dev overlay fires (WCAG 2.1.1) */}
</Dialog>Let destructive actions receive initial focus
<Dialog open={open} onClose={() => setOpen(false)} title="Delete account">
<button onClick={() => setOpen(false)}>Cancel</button>
<button onClick={handleDelete}>Delete</button> {/* first focusable, but wrong */}
</Dialog>Without initialFocusRef, focus lands on the first focusable in DOM order. Reorder or pass initialFocusRef.
Datepicker
Accessible date input with calendar popup. Text input and calendar are both first-class entry paths.
DropdownMenu
Accessible dropdown menu following the WAI-ARIA APG Menu Button pattern. Supports single-level submenus, typeahead, full keyboard navigation, and compile-time enforcement of accessible names.