← Back to Accessalyze

By Genesis AI Services · April 21, 2026 · 9 min read · Patterns

Accessible Modal Dialog: Complete Implementation

The accessibility requirements for a dialog: (1) announce role and title to screen readers, (2) trap focus inside while open, (3) Escape closes it, (4) focus returns to trigger on close, (5) page behind is inert.

The modal dialog is one of the most commonly broken accessible UI patterns. Getting it wrong means keyboard-only users get trapped inside, screen reader users have no context for what appeared, or focus disappears into the void on close.

What Makes a Modal Accessible

The HTML Structure

<!-- Trigger button -->
<button id="open-dialog-btn" type="button">Subscribe to newsletter</button>

<!-- Dialog (hidden by default) -->
<div id="subscribe-dialog"
     role="dialog"
     aria-modal="true"
     aria-labelledby="dialog-title"
     aria-describedby="dialog-desc"
     hidden>

  <h2 id="dialog-title">Subscribe to our newsletter</h2>
  <p id="dialog-desc">
    Enter your email to receive weekly accessibility tips and WCAG updates.
  </p>

  <form>
    <label for="dialog-email">Email address</label>
    <input type="email" id="dialog-email" autocomplete="email"
           aria-required="true">

    <div class="dialog-actions">
      <button type="submit">Subscribe</button>
      <button type="button" id="close-dialog-btn">Cancel</button>
    </div>
  </form>

</div>

<!-- Backdrop -->
<div id="dialog-backdrop" hidden aria-hidden="true"></div>

The JavaScript

class AccessibleDialog {
  constructor(dialogId, triggerId) {
    this.dialog = document.getElementById(dialogId);
    this.trigger = document.getElementById(triggerId);
    this.closeBtn = this.dialog.querySelector('[id$="close-dialog-btn"]');
    this.backdrop = document.getElementById('dialog-backdrop');
    this.focusableSelectors = [
      'a[href]', 'button:not([disabled])', 'input:not([disabled])',
      'select:not([disabled])', 'textarea:not([disabled])',
      '[tabindex]:not([tabindex="-1"])'
    ].join(', ');

    this.trigger.addEventListener('click', () => this.open());
    this.closeBtn.addEventListener('click', () => this.close());
    this.backdrop.addEventListener('click', () => this.close());
    this.dialog.addEventListener('keydown', (e) => this.handleKeydown(e));
  }

  open() {
    this.dialog.removeAttribute('hidden');
    this.backdrop.removeAttribute('hidden');

    // Make background inert (best option)
    document.getElementById('app').setAttribute('inert', '');
    // Fallback: aria-hidden on main content
    // document.getElementById('app').setAttribute('aria-hidden', 'true');

    // Focus the first focusable element in the dialog
    const firstFocusable = this.dialog.querySelector(this.focusableSelectors);
    if (firstFocusable) firstFocusable.focus();
    else this.dialog.setAttribute('tabindex', '-1'), this.dialog.focus();

    document.addEventListener('keydown', this.escapeHandler = (e) => {
      if (e.key === 'Escape') this.close();
    });
  }

  close() {
    this.dialog.setAttribute('hidden', '');
    this.backdrop.setAttribute('hidden', '');
    document.getElementById('app').removeAttribute('inert');
    this.trigger.focus(); // Return focus to trigger
    document.removeEventListener('keydown', this.escapeHandler);
  }

  handleKeydown(e) {
    if (e.key !== 'Tab') return;
    const focusable = [...this.dialog.querySelectorAll(this.focusableSelectors)];
    const first = focusable[0];
    const last = focusable[focusable.length - 1];

    if (e.shiftKey && document.activeElement === first) {
      e.preventDefault();
      last.focus();
    } else if (!e.shiftKey && document.activeElement === last) {
      e.preventDefault();
      first.focus();
    }
  }
}

// Initialize
new AccessibleDialog('subscribe-dialog', 'open-dialog-btn');

The CSS

/* Backdrop */
#dialog-backdrop {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.5);
  z-index: 100;
}

/* Dialog */
[role="dialog"] {
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  background: #fff;
  border-radius: 12px;
  padding: 32px;
  max-width: 480px;
  width: 90%;
  z-index: 101;
  box-shadow: 0 25px 50px rgba(0, 0, 0, 0.25);
}

/* Focus visible inside dialog */
[role="dialog"] :focus-visible {
  outline: 3px solid #2563eb;
  outline-offset: 2px;
}

Common Mistakes

Modern alternative: The native HTML <dialog> element (now well-supported across browsers) handles much of this automatically. It has built-in Escape handling, focus management, and top-layer stacking. Consider using it for new projects.

Check Your Modals and Interactive Components

Accessalyze identifies ARIA pattern violations and missing accessible names on interactive widgets.

See how 321 websites scored →

View the 2026 Report
Run Accessibility Audit →

← Back to Accessalyze

Accessalyze - Free WCAG 2.1 scanner that writes the fix code for you | Product Hunt

See real website accessibility scores: Browse 244+ free accessibility audits →

Try it yourself

Enter your website URL to get a free accessibility score.

Check your website accessibility score free Scan Now →