artui
Components

Dialog

View source

Accessible modal dialog using native <dialog> with showModal(). Browser-managed focus trap, top layer, and Escape handling.

npx artui@latest add dialog

Playground

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.

Delete account

This action cannot be undone.

You are about to delete your account. This is a playground; no real action is performed.

label source
description
initialFocusRef
returnFocusRef
closeOnBackdropClick

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

PropTypeDefaultDescription
titleAccessibleTextnoneVisible dialog heading rendered as an <h2>. Exactly one of title or aria-labelledby is required: passing neither or both is a compile error.
aria-labelledbystringnoneID of an existing element that labels this dialog. Mutually exclusive with title.
openrequiredbooleannoneControls whether the dialog is shown.
onCloserequired() => voidnoneCalled when the user closes the dialog (close button, Escape, or backdrop click). The consumer is responsible for setting open to false.
childrenrequiredReactNodenoneDialog body content. Rendering no content triggers a dev overlay.
descriptionAccessibleTextnoneOptional supplementary description rendered as a <p> and wired to the dialog via aria-describedby.
initialFocusRefRefObject<HTMLElement | null>noneRef to the element that should receive focus when the dialog opens. Falls back to the first focusable descendant.
returnFocusRefRefObject<HTMLElement | null>noneRef to the element that should receive focus when the dialog closes. Falls back to the element that was focused before the dialog opened.
closeOnBackdropClickbooleantrueWhether clicking the backdrop calls onClose.
classNamestringnoneAdditional CSS class applied to the <dialog> element.
idstringnoneApplied to the <dialog> element. Pass the same value to DialogTrigger's controls prop so aria-controls resolves correctly.

Keyboard

KeyAction
TabMove focus to next focusable element inside the dialog
Shift+TabMove focus to previous focusable element
EscapeClose 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

CriterionNameHow the component satisfies it
1.3.1(A)Info and RelationshipsThe 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)KeyboardThe 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 TrapFocus is trapped inside the dialog by the browser's native showModal() implementation. Escape always exits.
2.4.3(A)Focus OrderFocus 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 VisibleThe close button uses outline (not box-shadow) for its focus ring, preserving visibility in Windows High Contrast Mode.
4.1.2(A)Name, Role, ValueThe <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, ValueDialogTrigger 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.

On this page