Accessible Modals and Dialogs — Complete Guide
In this tutorial, you will learn about Accessible Modals and Dialogs. We cover key concepts, practical examples, and best practices to help you master this topic.
Accessible modals use focus trapping, aria-modal, aria-labelledby for accessible naming, Escape key handling, and focus return to the triggering element when dismissed.
What You'll Learn
- The dialog role and aria-modal attribute
- Focus management when opening and closing modals
- Focus trapping within the modal
- Keyboard expectations: Tab, Escape, Enter
- Inert background content
Why It Matters
- Modals interrupt the user's current task
- Poorly implemented modals trap or disorient users
- Focus management errors make modals unusable for keyboard users
- WCAG requires focus management and dismissible content
Real-World Use
- A confirmation dialog before deleting an item
- A login form that opens as a modal
- A cookie consent banner that must be dismissed
- An image gallery lightbox
flowchart LR
A[Open Modal] --> B[Store Last Focus]
B --> C[Show Modal]
C --> D[Move Focus Inside]
D --> E[Trap Focus]
E --> F{Escape or Overlay Click?}
F --> G[Close Modal]
G --> H[Return Focus]
H --> I[User at Original Position]
Modal Accessibility Fundamentals
A modal dialog is a window that appears on top of the main content and requires the user to interact with it before returning to the main page. For accessibility, modals must:
- Have
role="dialog"orrole="alertdialog" - Use
aria-modal="true"to indicate content behind is not interactive - Have an accessible name via
aria-labelledbyoraria-label - Trap keyboard focus inside the modal while open
- Close on Escape key press
- Return focus to the triggering element when closed
- Optionally close when clicking the backdrop
Code Example: Minimal Accessible Modal
<button id="open-dialog" onclick="openModal()">Delete Account</button>
<div id="confirm-dialog"
role="alertdialog"
aria-modal="true"
aria-labelledby="dialog-title"
aria-describedby="dialog-desc"
hidden
style="position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.5); display:flex; align-items:center; justify-content:center; z-index:1000;">
<div style="background:white; padding:2rem; max-width:400px; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.3);">
<h2 id="dialog-title">Confirm Deletion</h2>
<p id="dialog-desc">Are you sure you want to delete your account? This action cannot be undone.</p>
<div style="display:flex; gap:1rem; justify-content:flex-end; margin-top:1.5rem;">
<button id="cancel-btn" onclick="closeModal()">Cancel</button>
<button id="confirm-btn" onclick="deleteAccount()" style="background:#C62828; color:white; border:none; padding:0.5rem 1rem; border-radius:4px;">
Delete My Account
</button>
</div>
</div>
</div>
<script>
let lastFocusedElement = null;
function openModal() {
lastFocusedElement = document.activeElement;
const dialog = document.getElementById('confirm-dialog');
dialog.hidden = false;
// Focus the cancel button (first focusable, least destructive)
document.getElementById('cancel-btn').focus();
// Focus trap
dialog.addEventListener('keydown', trapFocus);
// Close on Escape
dialog.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeModal();
}
});
// Close on backdrop click
dialog.addEventListener('click', function(e) {
if (e.target === dialog) {
closeModal();
}
});
}
function closeModal() {
const dialog = document.getElementById('confirm-dialog');
dialog.hidden = true;
dialog.removeEventListener('keydown', trapFocus);
if (lastFocusedElement) {
lastFocusedElement.focus();
}
}
function trapFocus(e) {
const dialog = document.getElementById('confirm-dialog');
const focusableElements = dialog.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstFocusable = focusableElements[0];
const lastFocusable = focusableElements[focusableElements.length - 1];
if (e.key === 'Tab') {
if (e.shiftKey) {
if (document.activeElement === firstFocusable) {
e.preventDefault();
lastFocusable.focus();
}
} else {
if (document.activeElement === lastFocusable) {
e.preventDefault();
firstFocusable.focus();
}
}
}
}
function deleteAccount() {
// Perform deletion
alert('Account deleted');
closeModal();
}
</script>
Expected output: Clicking "Delete Account" opens the modal and moves focus to Cancel. Tab cycles between Cancel and Delete. Shift+Tab reverses the cycle. Escape closes the modal and returns focus to the Delete Account button. The backdrop click also closes the modal.
Code Example: Modal with Long Content
<button id="open-terms" onclick="openTermsModal()">View Terms and Conditions</button>
<div id="terms-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="terms-title"
hidden
style="position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.5); z-index:1000;">
<div style="background:white; max-width:600px; margin:2rem auto; max-height:80vh; display:flex; flex-direction:column; border-radius:8px; overflow:hidden;">
<div style="padding:1rem 2rem; border-bottom:1px solid #eee; display:flex; justify-content:space-between; align-items:center;">
<h2 id="terms-title" style="margin:0;">Terms and Conditions</h2>
<button onclick="closeTermsModal()" aria-label="Close terms" style="background:none; border:none; font-size:1.5rem; cursor:pointer;">x</button>
</div>
<div style="padding:2rem; overflow-y:auto; flex:1;" tabindex="0">
<h3>1. Acceptance of Terms</h3>
<p>By accessing this website, you agree to be bound by these terms...</p>
<h3>2. Use License</h3>
<p>Permission is granted to temporarily download one copy...</p>
<h3>3. Disclaimer</h3>
<p>The materials on this website are provided on an as-is basis...</p>
<!-- More terms... -->
</div>
<div style="padding:1rem 2rem; border-top:1px solid #eee; display:flex; justify-content:flex-end; gap:1rem;">
<button onclick="closeTermsModal()">Close</button>
<button onclick="acceptTerms()" style="background:#0056B3; color:white; border:none; padding:0.5rem 1rem; border-radius:4px;">Accept</button>
</div>
</div>
</div>
<script>
function openTermsModal() {
lastFocusedElement = document.activeElement;
document.getElementById('terms-dialog').hidden = false;
document.getElementById('terms-dialog').querySelector('[aria-label="Close terms"]').focus();
}
function closeTermsModal() {
document.getElementById('terms-dialog').hidden = true;
if (lastFocusedElement) lastFocusedElement.focus();
}
function acceptTerms() {
alert('Terms accepted');
closeTermsModal();
}
</script>
Expected output: The modal opens with focus on the Close button. The content area is scrollable with keyboard (arrow keys, Page Up/Down). Tab moves between Close and Accept. The header and footer remain visible while content scrolls.
Code Example: Non-Modal Dialog (Alert)
<!-- Non-modal alert: does not prevent interaction with page -->
<div role="alert"
id="notification"
hidden
style="position:fixed; top:1rem; right:1rem; background:#2E7D32; color:white; padding:1rem; border-radius:4px; z-index:1000;">
<div style="display:flex; gap:1rem; align-items:center;">
<span>File uploaded successfully!</span>
<button onclick="dismissNotification()" aria-label="Dismiss" style="background:none; border:none; color:white; cursor:pointer;">x</button>
</div>
</div>
<button onclick="showNotification()">Upload File</button>
<script>
function showNotification() {
const notif = document.getElementById('notification');
notif.hidden = false;
notif.querySelector('button').focus();
// Auto dismiss after 5 seconds
setTimeout(() => {
notif.hidden = true;
}, 5000);
}
function dismissNotification() {
document.getElementById('notification').hidden = true;
}
</script>
Expected output: The notification appears and focus moves to the dimiss button. Unlike a modal, the user can still interact with the page behind the notification. The role="alert" causes screen readers to announce the content immediately.
Common Mistakes
- No focus trap — Focus leaves the modal and users cannot return, getting stuck behind the overlay.
- Missing aria-modal — Without aria-modal, some screen readers do not restrict navigation to the modal content.
- No accessible name — A modal without aria-labelledby or aria-label is announced as "dialog" with no context about its purpose.
- Escape key not handled — Users cannot close the modal with the keyboard, forcing them to Tab through or refresh the page.
- Focus not returned on close — After closing the modal, focus remains wherever it was or resets to the top of the page.
- Backdrop click closes without warning — Clicking the overlay closes the modal, which is fine, but ensure focus is returned to the trigger.
- Multiple modals open simultaneously — Stacking modals creates confusion. Only one modal should be open at a time.
Practice Questions
- What ARIA attributes are required for an accessible modal? role="dialog" (or "alertdialog"), aria-modal="true", and aria-labelledby or aria-label for the accessible name.
- Where should focus go when a modal opens? To the first focusable element inside the modal, typically the primary action or "Cancel" button (least destructive option).
- What is focus trapping and why is it necessary for modals? Focus trapping prevents keyboard focus from leaving the modal while it is open. It is necessary so users do not interact with background content inadvertently.
- What keyboard key should close a modal? Escape.
- Challenge: Build a multi-step wizard modal with 3 steps (Shipping, Payment, Review). Each step has different form fields. The modal must: trap focus, manage focus when moving between steps, announce step changes via a live region, close on Escape, and return focus to the trigger on close. Test with a screen reader.
FAQ
{{< faq "What is the difference between role="dialog" and role="alertdialog"?" "Use alertdialog when the dialog requires an immediate response and contains alert information (like a confirmation dialog). Alertdialogs should have aria-describedby for the message." >}}
Mini Project
Build a complete image gallery lightbox. The lightbox must: open when any thumbnail is clicked, display the full image with caption, trap focus inside the lightbox, support Arrow keys for next/previous navigation, close on Escape, close on backdrop click, return focus to the clicked thumbnail when closed, announce image captions via aria-live, include a close button with aria-label, and support keyboard navigation through thumbnails (Tab) and inside the lightbox (Arrow keys). Include at least 6 images. Test with keyboard only and a screen reader.
What's Next
Continue with Lesson 19: Custom Widgets Accessibility to learn how to make complex custom components accessible.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro