Building accessible modal dialogs: a practical checklist
Modals are one of the most common sources of accessibility bugs we find in audits. Here's what a well-built dialog needs, from semantics and ARIA to focus management, labelling, and target size.
· 8 min read
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.
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.