Modal dialogs are one of the most consistently broken UI patterns I encounter in accessibility audits. I have been navigating the web with JAWS for over 25 years. In that time, modal dialogs have gone from rare to ubiquitous — and broken implementations have multiplied just as fast.

The problems are predictable: focus does not move into the dialog when it opens, or it does move in but lands on the backdrop instead of the first focusable element. Tab cycles through the entire page instead of staying within the dialog. Pressing Escape does nothing. When the dialog closes, focus is lost entirely rather than returning to the trigger element. Screen readers announce the content behind the modal alongside the modal content, creating a mixed and confusing reading experience.

All of these problems are solvable. Here is the complete pattern — the one that actually works across JAWS, NVDA, VoiceOver, and TalkBack.

The Minimum Requirements for an Accessible Modal

Before the code, let me enumerate what an accessible modal dialog must do:

  1. Focus management on open: When the dialog opens, keyboard focus must move into the dialog. Typically to the dialog title, the first interactive element, or a clearly designated initial focus target.
  2. Focus containment (trap): While the dialog is open, Tab and Shift+Tab must cycle only through elements within the dialog. Focus must not escape to the page behind the modal.
  3. Background inertness: Content behind the modal must be inaccessible to screen readers and keyboard navigation while the modal is open.
  4. Keyboard close: The Escape key must close the dialog.
  5. Focus restoration on close: When the dialog closes, focus must return to the element that triggered it.
  6. Correct ARIA semantics: The dialog container must have role="dialog", aria-modal="true", and an accessible name via aria-labelledby.

The HTML Structure

<!-- Trigger button -->
<button type="button" id="open-dialog-btn" aria-haspopup="dialog">
  Open settings
</button>

<!-- Backdrop: separate element, not a parent of the dialog -->
<div
  class="dialog-backdrop"
  id="dialog-backdrop"
  aria-hidden="true"
  hidden
></div>

<!-- Dialog container -->
<div
  role="dialog"
  aria-modal="true"
  aria-labelledby="dialog-title"
  id="settings-dialog"
  class="dialog"
  hidden
  tabindex="-1"
>
  <div class="dialog-inner">
    <div class="dialog-header">
      <h2 id="dialog-title">Account Settings</h2>
      <button
        type="button"
        class="dialog-close"
        aria-label="Close Account Settings dialog"
        id="dialog-close-btn"
      >
        <svg aria-hidden="true" focusable="false" width="20" height="20" viewBox="0 0 20 20">
          <path d="M4 4l12 12M16 4L4 16" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"/>
        </svg>
      </button>
    </div>

    <div class="dialog-body">
      <label for="display-name">Display name</label>
      <input type="text" id="display-name" autocomplete="name" />

      <label for="email-pref">Email preferences</label>
      <select id="email-pref">
        <option>All notifications</option>
        <option>Important only</option>
        <option>None</option>
      </select>
    </div>

    <div class="dialog-footer">
      <button type="button" class="btn-secondary">Cancel</button>
      <button type="button" class="btn-primary">Save changes</button>
    </div>
  </div>
</div>

Key Structural Decisions

Why tabindex="-1" on the dialog container? This allows us to programmatically focus the dialog container itself with JavaScript (element.focus()) when it does not contain an immediately appropriate interactive element as the initial focus target. The dialog element itself becomes focusable but is not in the Tab order.

Why aria-labelledby pointing to the title heading? This associates the dialog with its title so that when focus moves into the dialog, screen readers announce it as "Account Settings dialog." Without this, users hear "dialog" with no context about what they have entered.

Why aria-haspopup="dialog" on the trigger button? This tells screen readers that activating the button will open a dialog, setting the user's expectation before they click.

The JavaScript Implementation

class AccessibleDialog {
  constructor(dialogEl, triggerEl) {
    this.dialog = dialogEl;
    this.trigger = triggerEl;
    this.backdrop = document.getElementById('dialog-backdrop');
    this.closeBtn = dialogEl.querySelector('.dialog-close');
    this.lastFocus = null;

    this.focusableSelectors = [
      'a[href]',
      'button:not([disabled])',
      'input:not([disabled])',
      'select:not([disabled])',
      'textarea:not([disabled])',
      '[tabindex]:not([tabindex="-1"])'
    ].join(', ');

    this.bindEvents();
  }

  getFocusableElements() {
    return Array.from(
      this.dialog.querySelectorAll(this.focusableSelectors)
    ).filter(el => !el.closest('[hidden]') && el.offsetParent !== null);
  }

  open() {
    // Store reference to the element that triggered the dialog
    this.lastFocus = document.activeElement;

    // Show the dialog and backdrop
    this.dialog.removeAttribute('hidden');
    this.backdrop.removeAttribute('hidden');

    // Inert the background content (preferred over aria-hidden on multiple elements)
    document.querySelectorAll('body > *:not([role="dialog"]):not(script)').forEach(el => {
      if (el !== this.dialog && el !== this.backdrop) {
        el.setAttribute('inert', '');
      }
    });

    // Move focus into the dialog
    // Prefer the first focusable element; fall back to dialog container
    const focusable = this.getFocusableElements();
    const initialFocus = focusable.length > 0 ? focusable[0] : this.dialog;
    initialFocus.focus();
  }

  close() {
    // Hide dialog and backdrop
    this.dialog.setAttribute('hidden', '');
    this.backdrop.setAttribute('hidden', '');

    // Remove inert from background
    document.querySelectorAll('[inert]').forEach(el => {
      el.removeAttribute('inert');
    });

    // Return focus to the triggering element
    if (this.lastFocus && this.lastFocus.focus) {
      this.lastFocus.focus();
    }
    this.lastFocus = null;
  }

  trapFocus(event) {
    if (event.key !== 'Tab') return;

    const focusable = this.getFocusableElements();
    if (focusable.length === 0) return;

    const first = focusable[0];
    const last = focusable[focusable.length - 1];

    if (event.shiftKey) {
      // Shift+Tab: if on first element, wrap to last
      if (document.activeElement === first) {
        event.preventDefault();
        last.focus();
      }
    } else {
      // Tab: if on last element, wrap to first
      if (document.activeElement === last) {
        event.preventDefault();
        first.focus();
      }
    }
  }

  bindEvents() {
    // Open trigger
    this.trigger.addEventListener('click', () => this.open());

    // Close button inside dialog
    if (this.closeBtn) {
      this.closeBtn.addEventListener('click', () => this.close());
    }

    // Escape key closes dialog
    this.dialog.addEventListener('keydown', (e) => {
      if (e.key === 'Escape') {
        e.stopPropagation();
        this.close();
      }
      this.trapFocus(e);
    });

    // Backdrop click closes dialog (optional — some dialogs require explicit confirmation)
    this.backdrop.addEventListener('click', () => this.close());
  }
}

// Initialize
const dialog = document.getElementById('settings-dialog');
const trigger = document.getElementById('open-dialog-btn');
new AccessibleDialog(dialog, trigger);

The CSS

/* Backdrop */
.dialog-backdrop {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.7);
  z-index: 1000;
}

/* Dialog container */
.dialog {
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  z-index: 1001;
  width: min(560px, calc(100vw - 2rem));
  max-height: calc(100vh - 4rem);
  overflow-y: auto;
  background: #0f1117;
  border: 1px solid #1e2330;
  border-radius: 12px;
  box-shadow: 0 24px 64px rgba(0, 0, 0, 0.8);
}

/* Ensure focus ring is visible on dialog container itself */
.dialog:focus-visible {
  outline: 3px solid #66FCF1;
  outline-offset: 3px;
}

.dialog-inner {
  display: flex;
  flex-direction: column;
}

.dialog-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 1.5rem;
  border-bottom: 1px solid #1e2330;
}

.dialog-header h2 {
  font-size: 1.25rem;
  font-weight: 700;
  color: #fff;
  margin: 0;
}

.dialog-close {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 36px;
  height: 36px;
  background: none;
  border: 1px solid #1e2330;
  border-radius: 6px;
  color: #7a7e8a;
  cursor: pointer;
  transition: border-color 0.15s, color 0.15s;
}

.dialog-close:hover {
  border-color: #66FCF1;
  color: #66FCF1;
}

.dialog-body {
  padding: 1.5rem;
  display: flex;
  flex-direction: column;
  gap: 1rem;
}

.dialog-footer {
  display: flex;
  justify-content: flex-end;
  gap: 0.75rem;
  padding: 1.5rem;
  border-top: 1px solid #1e2330;
}

/* Reduce motion */
@media (prefers-reduced-motion: reduce) {
  .dialog {
    transform: translate(-50%, -50%) !important;
  }
}

Why aria-modal="true" Alone Is Not Enough

I want to address a common misconception. aria-modal="true" tells screen readers that the dialog is a modal and that content outside it should be treated as inert. In theory. In practice, support for this attribute is inconsistent across screen reader and browser combinations.

JAWS with Chrome handles aria-modal reasonably well. NVDA's support improved significantly in 2024 but still has edge cases. VoiceOver on iOS has historically ignored it in some situations. The inert attribute, applied to all background content via JavaScript, is the reliable cross-platform solution. Use both: aria-modal="true" for conformance and progressive enhancement, and inert for actual functional containment.

Common Implementation Mistakes I Find in Audits

Focus Lands on the Backdrop

When the backdrop is a sibling of the dialog rather than a separate element, and the dialog container does not explicitly receive focus, the backdrop can accidentally receive focus. The backdrop must not be focusable (aria-hidden="true" and no tabindex on the backdrop element).

Escape Key Closes the Wrong Thing

A global document.addEventListener('keydown', handleEscape) that closes modals is fine — but make sure it handles multiple stacked modals correctly. If a select dropdown or date picker is open within the modal, the first Escape should close that widget, not the entire dialog. This requires scope-aware escape handling.

Focus Trap Does Not Account for Dynamically Added Elements

If the dialog can load additional content dynamically — a loading state followed by results, for example — the list of focusable elements must be computed fresh each time trapFocus runs, not cached at initialization. The implementation above calls getFocusableElements() dynamically for this reason.

Hidden Elements in the Tab Order

A dialog that animates in with CSS may have content that is visually hidden during the animation but technically in the DOM and focusable. If your focus trap runs before the animation completes, it may include elements that are not yet visible. Use el.offsetParent !== null (as shown above) to filter out hidden elements from the focusable list.

Testing Your Modal Implementation

After building your modal, run through this test script with NVDA and VoiceOver:

  1. Navigate to the trigger button with Tab.
  2. Activate the button. Verify: the dialog opens AND focus moves inside it AND the screen reader announces the dialog title and role.
  3. Tab forward through all focusable elements. Verify: Tab wraps from the last element back to the first without escaping to the page.
  4. Shift+Tab from the first element. Verify: focus wraps to the last element.
  5. In browse mode (NVDA), use arrow keys to navigate within the dialog. Verify: you cannot navigate to content outside the dialog.
  6. Press Escape. Verify: the dialog closes AND focus returns to the trigger button.
  7. Click the close button. Verify: the same focus restoration behavior.
  8. Click outside the dialog on the backdrop (if close-on-backdrop-click is implemented). Verify: focus restoration behavior.

If your implementation passes all eight steps with both JAWS/NVDA and VoiceOver, you have a genuinely accessible modal dialog. That is the standard. It is achievable, and your users — all of them — will benefit from getting it right.

Need a real accessibility audit?

Not a Lighthouse score — a thorough WCAG 2.2 AA audit with manual JAWS testing and code-level remediation guidance your developers can actually use.

Schedule a Free Consultation