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.
role="dialog" — announces the dialog role to screen readersaria-modal="true" — tells screen readers that content outside is inertaria-labelledby — points to the dialog's heading (its accessible name)aria-describedby — optionally points to a description paragraphinert (or aria-hidden)<!-- 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>
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');
/* 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;
}
aria-hidden="true" on the dialog container (hides it from screen readers entirely)aria-hidden="true" on the background but not using inert — keyboard focus can still reach background elementstabindex="-1"overflow: hidden that clips the dialog<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.
Accessalyze identifies ARIA pattern violations and missing accessible names on interactive widgets.
See how 321 websites scored →
View the 2026 ReportSee real website accessibility scores: Browse 244+ free accessibility audits →
Try it yourself
Enter your website URL to get a free accessibility score.