Semantics & structure

  • Use the ARIA dialog pattern. Put role="dialog" on the dialog surface itself. Use role="alertdialog" only when the dialog interrupts the user with something urgent that needs acknowledgment, such as a destructive-action confirmation. Don't use it for routine dialogs: alertdialog is announced more assertively, and overusing it trains users to ignore the urgency signal.
  • Add aria-modal="true" to tell assistive technology that everything outside the dialog is inert while it's open. Pair it with actually making the background inert (see Keyboard accessibility). aria-modal alone is a declaration, not an enforcement mechanism, and some screen reader and browser combinations don't fully honor it without real DOM-level isolation.
  • Render the dialog at the end of the DOM (or via a portal) rather than deep inside its trigger component's markup. That way tab order and focus trapping behave predictably, no matter where the trigger sits in the page.
Minimal dialog markup
<div role="dialog" aria-modal="true"
     aria-labelledby="dlg-title" aria-describedby="dlg-desc">
  <h2 id="dlg-title">Delete invoice #1042?</h2>
  <p id="dlg-desc">This can't be undone.</p>
  <button type="button">Cancel</button>
  <button type="button">Delete invoice</button>
  <button type="button" aria-label="Close dialog">×</button>
</div>

ARIA attributes & state

  • aria-labelledby points to the element containing the dialog's visible title. If there's no visible title (rare, but it happens with minimal confirmation popups), set aria-label on the dialog instead. Never leave both out: an unlabeled dialog is announced only as "dialog", which tells screen reader users nothing about what they've entered.
  • aria-describedby points to the dialog's body content, so the description is announced right after the name when the dialog receives focus. It supplements the label; it doesn't replace it. Don't point aria-describedby at the content and skip aria-labelledby or aria-label.
  • Never put aria-hidden="true" on the dialog or any of its ancestors, only on sibling or background content. A dialog that's aria-modal="true" but also accidentally hidden becomes invisible to assistive technology entirely.
  • Announce dynamic messages. If the dialog shows form validation errors or status messages, use aria-live="polite" (or role="alert" for urgent ones) on the region where they appear, so corrections are announced without the user having to go looking for them.

Keyboard accessibility

  • Move focus into the dialog when it opens. By default that's the first focusable element. Choose a different target when it makes more sense: for example, the heading (with tabindex="-1") when interacting with a control straight away would be premature or risky, such as a destructive default action. Never leave focus on the trigger or, worse, on <body>.
  • Trap focus while the dialog is open. Tab and Shift+Tab cycle only through focusable elements inside the dialog, wrapping from last to first and back. Background content must be truly unreachable by keyboard, not just visually covered: use inert on everything outside the dialog, or set tabindex="-1" on all background focusable elements where inert isn't available.
  • Escape closes the dialog, unless closing it would lose unsaved changes. In that case, either confirm before closing or disable Escape and rely on explicit buttons. Treat this as a deliberate exception, not the default.
  • Return focus when the dialog closes, whether by Escape, the close button, a backdrop click, or a successful submit. Focus goes back to the trigger. If the trigger no longer exists (say it was part of a list item that was just deleted), move focus to a sensible nearby fallback, like the next item or the parent container, rather than letting it fall to <body>.
  • Keep a logical tab order that follows the visual reading order. Primary action buttons usually come last, matching the left-to-right or right-to-left convention of the interface's locale.
  • Show a visible focus indicator on whatever element has focus, with at least 3:1 contrast against its background (WCAG 1.4.11 Non-text Contrast). This matters most when the dialog opens, since finding focus is the first thing a sighted keyboard user needs to do.

Labeling

  • Make the title a real heading (for example, <h2>), both as the aria-labelledby target and so the dialog fits correctly into the page's heading structure for screen reader navigation.
  • Make the close button a real <button>, not a styled <div> or bare icon, with an accessible name such as aria-label="Close dialog". Icon-only buttons without a name are announced as just "button", which means nothing.
  • Give action buttons specific names. "Delete" is fine inside a single dialog. But if the same pattern repeats across many dialogs, like delete confirmations for every row in a list, consider including the subject (for example, "Delete invoice #1042") so the name is unambiguous when it appears in a screen reader's list of elements.

Loading & dynamic content

  • Announce async content. If the dialog's content loads after it opens, announce the loading and completed states with aria-live="polite". An empty or spinner-only dialog that fills in silently gives no signal that content has arrived.
  • Show the busy state while submitting. When a form submission is in progress, indicate it on the submit button, either with aria-busy="true" or a disabled state with an updated accessible name, so screen reader users know their action registered.
  • Announce failures and move focus. If submission fails, announce the error (via aria-live or role="alert") and move focus to the error summary or the first invalid field. Don't leave focus stranded on a button that silently returned an error.

Contrast & target size

  • Text contrast: the title and body text need at least 4.5:1 for normal text and 3:1 for large text (WCAG 1.4.3).
  • Close icon contrast: the close button's icon needs 3:1 non-text contrast against its background (WCAG 1.4.11). It's a meaningful control, not decoration.
  • Target size: every interactive element in the dialog, including the close button, action buttons, and form controls, needs a target of at least 24×24 CSS pixels (WCAG 2.5.8), with enough spacing between neighbours, like Cancel and Delete, to prevent accidental activation.
  • Backdrop clicks: if clicking the backdrop closes the dialog, its target area should cover the whole viewport outside the dialog. Check that this behaviour is actually wanted, though: an accidental backdrop click can lose unsaved data just like an accidental Escape press.