Why Accessible Modals Are Non-Negotiable

Modal dialogs serve as focused communication tools: confirmation prompts, login forms, image galleries, or complex multi-step wizards. When built without accessibility, they become barriers rather than aids. Users who rely on keyboard navigation or screen readers can find themselves trapped in a hidden interface, unable to interact with the site. According to the WAI-ARIA Authoring Practices Guide, a modal dialog must manage focus, support keyboard escape, and convey its role to assistive technology. This article walks through building a custom JavaScript modal that meets these standards while remaining flexible for real-world use.

Understanding the Core Accessibility Requirements

Before writing code, it helps to list the behaviors that users expect from a modal:

  • Focus trapping: Tab and Shift+Tab cycle only through elements inside the modal while it is open. The user cannot accidentally tab behind the overlay.
  • Escape key close: Pressing Esc dismisses the modal and returns focus to the trigger element.
  • Focus restoration: When the modal closes, focus goes back to the element that opened it (usually a button or link).
  • ARIA properties: role="dialog" or "alertdialog", aria-modal="true", aria-labelledby pointing to the modal title, and optionally aria-describedby for longer descriptions.
  • Inert background content: Content behind the modal should not be reachable by keyboard or screen reader. Browsers now support the inert attribute for this.
  • Visible focus indicators: All interactive elements must have a visible focus ring.

Why Not Just Use a Third-Party Library?

Many UI libraries provide accessible modals out of the box. However, building one from scratch gives you full control over markup, styling, and behavior. You also avoid extra dependencies. For production apps, a library like a11y-dialog might be a better time investment. But for learning or custom use cases, a hand‑rolled solution is often the right choice.

HTML Structure of an Accessible Modal

The first step is writing semantic markup. The modal container should be a sibling of the main page content, not nested inside it. This keeps the DOM order logical and makes it easier to apply inert to the rest of the page.

<!-- Trigger button -->
<button id="openModal" type="button">Open Settings</button>

<div id="settingsModal" class="modal" role="dialog" aria-modal="true" aria-labelledby="modalTitle" aria-describedby="modalDesc" hidden>
  <div class="modal__backdrop" tabindex="-1"></div>
  <div class="modal__content">
    <h2 id="modalTitle">Settings</h2>
    <p id="modalDesc">Adjust your preferences below.</p>
    <!-- form elements go here -->
    <button type="button" class="modal__close" aria-label="Close settings">×</button>
  </div>
</div>

Notice the hidden attribute. This hides the modal from all users, including screen readers, until JavaScript removes it. The backdrop sits inside the modal container, so clicking outside the content area triggers the close handler.

Core JavaScript Implementation

The JavaScript handles opening, closing, focus management, and keyboard interactions. We’ll build a class-based approach for clarity and reuse.

Initializing the Modal

First, query the trigger and modal elements, and store references to all focusable children. A comprehensive selector for focusable elements includes a[href], button, input, textarea, select, and elements with a non-negative tabindex.

class Modal {
  constructor(triggerId, modalId) {
    this.trigger = document.getElementById(triggerId);
    this.modal = document.getElementById(modalId);
    this.focusableElements = this.modal.querySelectorAll(
      'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
    );
    this.firstFocusable = this.focusableElements[0];
    this.lastFocusable = this.focusableElements[this.focusableElements.length - 1];
    this.previouslyFocused = null;

    this.trigger.addEventListener('click', () => this.open());
    this.modal.addEventListener('click', (e) => {
      if (e.target === this.modal || e.target.classList.contains('modal__backdrop')) {
        this.close();
      }
    });
    this.modal.querySelector('.modal__close').addEventListener('click', () => this.close());
    document.addEventListener('keydown', (e) => this.handleKeydown(e));
  }
  // ...
}

Opening the Modal

When opening, remove the hidden attribute, store the currently focused element, set focus to the first focusable element inside the modal, and optionally add inert to the rest of the page. The inert attribute prevents any interaction with background content and hides it from the accessibility tree.

open() {
  this.previouslyFocused = document.activeElement;
  this.modal.removeAttribute('hidden');
  document.body.classList.add('modal-is-open');
  if (this.firstFocusable) {
    this.firstFocusable.focus();
  }
  // Make background content inert
  document.querySelectorAll('.page-wrapper, .site-header, .site-footer').forEach(el => {
    el.setAttribute('inert', '');
  });
}

Closing the Modal

Closing adds the hidden attribute back, removes inert from the background, and returns focus to the trigger element.

close() {
  if (this.modal.hasAttribute('hidden')) return;
  this.modal.setAttribute('hidden', '');
  document.body.classList.remove('modal-is-open');
  document.querySelectorAll('[inert]').forEach(el => el.removeAttribute('inert'));
  if (this.previouslyFocused) {
    this.previouslyFocused.focus();
  }
}

Keyboard Handling

The handleKeydown method checks whether the modal is open (i.e., not hidden). If so, it intercepts the Tab key to trap focus and closes on Escape.

handleKeydown(e) {
  if (this.modal.hasAttribute('hidden')) return;

  if (e.key === 'Escape') {
    e.preventDefault();
    this.close();
    return;
  }

  if (e.key === 'Tab') {
    const isTabOutside = (e.shiftKey && document.activeElement === this.firstFocusable) ||
                         (!e.shiftKey && document.activeElement === this.lastFocusable);
    if (isTabOutside) {
      e.preventDefault();
      if (e.shiftKey) {
        this.lastFocusable.focus();
      } else {
        this.firstFocusable.focus();
      }
    }
  }
}

Styling Considerations for Accessibility

CSS plays a supporting role. The overlay must be full‑screen and semi‑transparent. The modal content should be centered and have a high enough z‑index. Crucially, the close button should have a visible focus outline that follows the WCAG 2.1 non‑text contrast requirements (at least 3:1 ratio).

.modal {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1000;
}

.modal[hidden] {
  display: none;
}

.modal__backdrop {
  position: absolute;
  inset: 0;
  background: rgba(0, 0, 0, 0.5);
}

.modal__content {
  position: relative;
  background: #fff;
  padding: 2rem;
  border-radius: 8px;
  max-width: 600px;
  width: 90%;
  box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
}

.modal__close {
  position: absolute;
  top: 1rem;
  right: 1rem;
  background: none;
  border: none;
  font-size: 1.5rem;
  cursor: pointer;
  padding: 0.25rem;
  line-height: 1;
}

.modal__close:focus-visible {
  outline: 2px solid #005fcc;
  outline-offset: 2px;
}

Handling Dynamic Content and Focus

Modals often contain forms, buttons, or links that change. If the modal content updates after opening (e.g., an AJAX submission changes the form), you must re‑query the focusable elements. Call a method that rebuilds the focusable list and moves focus to the appropriate element. For example, after a successful submission that shows a success message, focus should go to that message or a “Continue” button.

refreshFocusables() {
  this.focusableElements = this.modal.querySelectorAll(
    'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
  );
  this.firstFocusable = this.focusableElements[0];
  this.lastFocusable = this.focusableElements[this.focusableElements.length - 1];
}

Advanced Accessibility Enhancements

Using the inert Attribute

The inert attribute is now supported in all modern browsers. When a container has inert, its children are removed from the Tab order and the accessibility tree. This is a cleaner alternative to manually managing aria-hidden on all siblings of the modal. In the open() method above, we added inert to the page wrapper elements. A more robust approach is to add inert to the body’s children that are not the modal itself. There is a polyfill for older browsers.

Handling Alert Dialogs

If the modal demands immediate attention (e.g., confirming a destructive action), use role="alertdialog". Screen readers may announce the content differently. Ensure the dialog’s message is concise and focus is automatically placed on the affirmative action button.

Long Content and Scroll Behavior

If the modal content exceeds the viewport, allow scrolling inside the modal while preventing the background from scrolling. Use overflow-y: auto on .modal__content and add body.modal-is-open { overflow: hidden; } to prevent body scroll. Test with very long content to ensure the focus trap still works and the close button remains accessible.

Testing Your Modal for Accessibility

Automated and manual testing are both necessary. Run the page through tools like axe DevTools or Lighthouse. Then test manually with a screen reader (NVDA on Windows, VoiceOver on macOS). Confirm that:

  • Opening the modal announces the title and description.
  • Tab moves through all interactive elements in order.
  • Shift+Tab works backwards.
  • Escape closes and returns to the trigger.
  • No focus lands on background content.
  • The backdrop dismisses the modal on click.

Also test with keyboard only: navigate using Tab and Enter, and verify that all functionality works without a mouse.

Common Pitfalls and How to Avoid Them

  • Missing aria-labelledby: Without a title, screen readers may announce “dialog” without context. Always provide a visible title and point aria-labelledby to it.
  • Focus jumping to body on close: If the trigger is not focusable or disappears, focus() on previouslyFocused will throw or do nothing. Ensure the trigger remains in the DOM and is focusable.
  • Nested modals: Avoid opening a modal from inside another modal. If necessary, manage a stack of focus histories and return focus correctly. Most designs discourage this pattern.
  • Animations and display: none: If you use CSS transitions (e.g., fade in), use visibility and opacity instead of toggling display. Otherwise, you cannot animate in. However, when using hidden, the element is display: none by default; you can override that with CSS if you keep the hidden attribute but use visibility. This is tricky. Often simpler: use a class for the open state and keep the element always in the DOM.

Putting It All Together

Here is a complete, self‑contained example that you can adapt. It includes the HTML, CSS, and JavaScript class we just built. The modal opens with a smooth fade, traps focus, and returns focus to the trigger on close. Use this as a foundation for any modal in your project.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Accessible Modal Example</title>
  <style>
    /* ... paste the CSS from above ... */
  </style>
</head>
<body>
  <button id="openModal" type="button">Open Modal</button>
  <div id="demoModal" class="modal" role="dialog" aria-modal="true" aria-labelledby="demoTitle" hidden>
    <div class="modal__backdrop"></div>
    <div class="modal__content">
      <h2 id="demoTitle">Accessible Modal</h2>
      <p>This modal is fully keyboard and screen reader accessible.</p>
      <button type="button" class="modal__close" aria-label="Close">×</button>
    </div>
  </div>
  <script>
    // ... paste the Modal class and instantiate it ...
    new Modal('openModal', 'demoModal');
  </script>
</body>
</html>

Performance and SEO Considerations

Because modals are hidden by default, their content does not impact page load performance significantly. However, avoid loading heavy resources (large images, videos) inside a modal unless they are likely to be opened. You can lazy‑load content only when the modal is opened. For SEO, content inside a hidden modal is still in the DOM and can be indexed. If you want to prevent indexing, use JavaScript to inject the content only on activation.

Conclusion

Building a custom modal dialog with accessibility means paying careful attention to focus management, keyboard navigation, ARIA roles, and visual design. The code provided here forms a robust base that you can extend for complex use cases like multi‑step forms, video embeds, or confirmation dialogs. Always test with real assistive technology and keep the Web Content Accessibility Guidelines (WCAG) as your benchmark. Accessible modals are not only ethically important—they also improve the user experience for everyone.