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
- Focus moves into the modal on open.
- Tab key cycles only within the modal (focus trap).
- Escape key closes the modal.
- Focus returns to the triggering element on close.
- Background content is not interactive while modal is open.
- 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.