September 19, 2022 · reala11y team
Building accessible modal dialogs on WordPress
Accessible modals need focus trapping, Escape to close, focus return, and the right ARIA. Here's how to build them right — and fix the popups WordPress plugins ship broken.
Modals are everywhere on WordPress: newsletter pop-ups, login forms, cookie notices, “are you sure?” confirmations, image lightboxes. They’re also one of the most reliably broken patterns for keyboard and screen-reader users. A modal that traps a mouse user’s attention but lets a keyboard user tab straight out into the dimmed page behind it isn’t a dialog — it’s a trap of a different, worse kind.
Getting a dialog right isn’t hard, but it’s exacting. There are five behaviours you have to get correct, and most popup plugins get two or three of them. Let’s walk the whole list.
What an accessible dialog has to do
A modal dialog is a window that takes over the interface: while it’s open, the rest of the page is inert. To honour that promise for everyone, it needs five things.
-
Move focus into the dialog when it opens. When the modal appears, keyboard focus should land on it — typically the first focusable control, or the dialog container itself. If focus stays on the trigger button behind the overlay, a keyboard user has no idea anything happened.
-
Trap focus inside while open. Tabbing past the last focusable element should wrap to the first;
Shift+Tabfrom the first should wrap to the last. Focus must not escape into the page behind the dialog. -
Close on
Escape. PressingEscapecloses the dialog. This is a near-universal expectation; omitting it strands keyboard users. -
Return focus to the trigger on close. When the dialog closes, focus goes back to the element that opened it — not to the top of the page. Losing focus position is disorienting and slow.
-
Announce itself correctly to assistive tech. The right roles and properties tell a screen reader “this is a dialog, it’s modal, and here’s its name.”
The markup and ARIA
The semantics come down to a few attributes:
<div role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
aria-describedby="dialog-desc">
<h2 id="dialog-title">Join the newsletter</h2>
<p id="dialog-desc">One email a week. Unsubscribe anytime.</p>
<!-- form fields, focusable controls -->
<button type="button" class="dialog-close" aria-label="Close dialog">×</button>
</div>
role="dialog"names the pattern. (Userole="alertdialog"only for urgent interruptions like a destructive-action confirmation.)aria-modal="true"tells assistive tech the rest of the page is inert while this is open.aria-labelledbypoints at the visible title so the dialog has an accessible name. If there’s no visible title, usearia-labelinstead.aria-describedby(optional) points at supporting text.- The close button needs its own accessible name — an
×glyph alone reads as nothing useful, so addaria-label="Close dialog".
A modern shortcut worth knowing: the native HTML <dialog> element with .showModal() gives you focus trapping, Escape-to-close, an inert backdrop, and the dialog role essentially for free. Browser support is solid now. If you’re building from scratch, reach for it before hand-rolling JavaScript — you inherit correct behaviour instead of reimplementing it.
The focus-trap logic
If you can’t use native <dialog>, the trap is a small amount of JavaScript. The shape of it:
function trapFocus(dialog, triggerEl) {
const focusable = dialog.querySelectorAll(
'a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
first.focus(); // move focus in
dialog.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { close(); return; }
if (e.key !== 'Tab') return;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus(); // wrap backward
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus(); // wrap forward
}
});
function close() {
dialog.hidden = true;
triggerEl.focus(); // return focus to the opener
}
}
That covers move-in (first.focus()), the wrap in both directions, Escape, and focus return (triggerEl.focus()). For belt-and-braces, also mark the rest of the page inert (or aria-hidden="true") while the dialog is open so a screen reader can’t wander into the background content.
Common WordPress popup problems
In rough order of how often we run into them:
- No focus trap. The popup appears, but
Tabwalks straight past it into the page behind. The single most common failure. - No
Escapehandler. Mouse users click the dim backdrop to dismiss; keyboard users have no equivalent. - Focus never moves in. The modal is visually centred but focus is still on the button that opened it, so it’s invisible to a keyboard user until they happen to tab into it.
- Focus lost on close. The dialog closes and focus drops to
<body>, sending the reader back to the top of the page. - No accessible name.
role="dialog"with noaria-labelledby/aria-label, so a screen reader announces an unnamed dialog. - Exit-intent and timed pop-ups that steal focus the moment they fire, interrupting whatever the reader was doing.
If you use a popup plugin, test it before you trust it: open the modal with the keyboard only, Tab all the way around, press Escape, and confirm focus comes back to the trigger. Many builders have improved, but plenty still ship dialogs missing half this list — and because the behaviour lives in the plugin’s own JavaScript and markup, the durable fix is in the code, not in a layer painted over it. An overlay widget can’t repair a broken focus trap; it doesn’t own the dialog’s event handlers. reala11y works at the source, flagging issues like an unnamed dialog so the rendered markup your visitors and their assistive tech receive is the thing that changed.
Where automated tooling fits — and where it doesn’t
A scanner can catch the static signals: a role="dialog" with no accessible name, a close button with no label. What it can’t fully judge is the behaviour — whether focus actually moves in, whether the trap holds, whether Escape works — because that’s runtime interaction a human verifies by tabbing through. The honest framing we hold to: automated tools, ours included, detect roughly 30–40% of WCAG issues by criteria, and dialog behaviour is squarely in the part a person has to test.
The honest takeaway
An accessible dialog is five behaviours: focus in, focus trapped, Escape to close, focus returned, and the right ARIA naming it. Reach for native <dialog> when you can — it hands you most of that for free — and keyboard-test every popup plugin before you ship it. Do that, and you’re moving toward WCAG 2.2 AA conformance on this front the right way: by making your modals work for the people who navigate without a mouse.