Why Most Modals Are Broken

Modal dialogs are one of the most commonly misimplemented UI components on the web. The visual result is usually fine — a box appears over the page — but behind the scenes, the experience for keyboard and screen reader users is often a disaster. Focus escapes the dialog, the background is still interactive, and closing requires a mouse.

A truly accessible modal must handle: focus trapping, keyboard dismissal, ARIA roles, and scroll locking.

The HTML Structure

Start with semantic, ARIA-annotated markup:

<button id="open-modal" aria-haspopup="dialog">Open Modal</button>

<div
  id="my-modal"
  role="dialog"
  aria-modal="true"
  aria-labelledby="modal-title"
  aria-describedby="modal-desc"
  hidden
>
  <div class="modal-backdrop"></div>
  <div class="modal-content">
    <h2 id="modal-title">Confirm Action</h2>
    <p id="modal-desc">Are you sure you want to proceed?</p>
    <button id="close-modal">Cancel</button>
    <button>Confirm</button>
  </div>
</div>

Key attributes explained:

  • role="dialog" — tells assistive technology this is a dialog.
  • aria-modal="true" — signals that background content is inert.
  • aria-labelledby — links the dialog to its heading for announcement.
  • hidden — hides the modal from both view and accessibility tree when closed.

The CSS

.modal-backdrop {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.5);
  z-index: 100;
}

.modal-content {
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  background: white;
  padding: 2rem;
  border-radius: 8px;
  z-index: 101;
  max-width: 480px;
  width: 90%;
}

[hidden] { display: none !important; }

The JavaScript: Focus Trap & Keyboard Handling

The most critical part is trapping focus inside the dialog while it's open:

const modal = document.getElementById('my-modal');
const openBtn = document.getElementById('open-modal');
const closeBtn = document.getElementById('close-modal');

const focusableSelectors = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';

function openModal() {
  modal.removeAttribute('hidden');
  document.body.style.overflow = 'hidden';
  const firstFocusable = modal.querySelectorAll(focusableSelectors)[0];
  firstFocusable?.focus();
}

function closeModal() {
  modal.setAttribute('hidden', '');
  document.body.style.overflow = '';
  openBtn.focus(); // Return focus to trigger
}

modal.addEventListener('keydown', (e) => {
  if (e.key === 'Escape') closeModal();

  if (e.key === 'Tab') {
    const focusable = [...modal.querySelectorAll(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();
    }
  }
});

openBtn.addEventListener('click', openModal);
closeBtn.addEventListener('click', closeModal);

Using the Native <dialog> Element

Modern browsers support the native HTML <dialog> element, which handles many accessibility concerns automatically. It includes built-in .showModal() and .close() methods and fires a close event. If you can target modern browsers, consider using it — but always test with a screen reader regardless.

Checklist for an Accessible Modal

  1. Focus moves into the modal on open.
  2. Tab key cycles only within the modal (focus trap).
  3. Escape key closes the modal.
  4. Focus returns to the triggering element on close.
  5. Background content is not interactive while modal is open.
  6. Modal is announced correctly by screen readers via ARIA.

Conclusion

Building an accessible modal takes more thought than throwing a div over your page — but the result is a component that works for every user. Prioritize accessibility from the start, and you'll save yourself costly retrofits later.