reala11y GUIDES Buildingaccessible modaldialogs on… Sep 19, 2022

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.

  1. 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.

  2. Trap focus inside while open. Tabbing past the last focusable element should wrap to the first; Shift+Tab from the first should wrap to the last. Focus must not escape into the page behind the dialog.

  3. Close on Escape. Pressing Escape closes the dialog. This is a near-universal expectation; omitting it strands keyboard users.

  4. 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.

  5. 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>

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:

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.