Accessible Modal Dialogs
Modal dialogs are everywhere in modern web applications, from simple confirmation boxes to complex forms and video players. While they might seem straightforward to implement, creating a truly accessible modal experience requires careful attention to detail. In this post, we’ll explore how to build modal dialogs that work for everyone, regardless of how they interact with your website.
The Accessibility Challenge
Many modal implementations fall short when it comes to accessibility. Common issues include:
- Keyboard users getting trapped or lost within the modal
- Screen readers not announcing the modal properly
- Focus management problems when opening and closing
- Content behind the modal remaining interactive
- Poor responsive design on mobile devices
Let’s dive into a solution that addresses all these challenges.
Key Features of an Accessible Modal
1. Proper ARIA Attributes
Our modal implementation uses the correct ARIA attributes to ensure screen readers understand the content:
<div id="exampleModal" class="modal-dialog" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<div class="modal-dialog-wrap">
<div class="modal-dialog-content">
<h2 id="modalTitle" tabindex="-1" class="dialog-label">
Modal Title
</h2>
<!-- Content here -->
</div>
</div>
</div>
The role="dialog"
tells assistive technology this is a modal dialog, while aria-modal="true"
indicates it’s a modal context. The aria-labelledby
attribute connects the modal to its title, providing context for screen reader users.
2. Focus Management
One of the trickiest parts of modal accessibility is managing focus correctly. Our solution implements a focus trap that:
- Captures focus when the modal opens
- Prevents focus from leaving the modal while it’s open
- Returns focus to the trigger element when the modal closes
clmodalkeyboardtrap(clmodal) {
const focusableElements =
'a[href], area[href], input:not([disabled]), select:not([disabled]), ' +
'textarea:not([disabled]), button:not([disabled]), object, embed, ' +
'[tabindex="0"], [contenteditable]';
const firstFocusableElement = modal.querySelectorAll(focusableElements)[0];
const focusableContent = modal.querySelectorAll(focusableElements);
const lastFocusableElement = focusableContent[focusableContent.length - 1];
// Focus trap implementation...
}
3. Responsive Design
Modern web applications need to work across all device sizes. Our modal system includes responsive breakpoints that adjust the modal size and positioning:
.modal-dialog.modal-open {
margin: 1rem;
max-width: 100%;
}
@media only screen and (min-width: 64.0625em) {
.modal-dialog.modal-open.modal-large .modal-dialog-wrap {
max-width: 778px;
}
.modal-dialog.modal-open.modal-medium .modal-dialog-wrap {
max-width: 568px;
}
.modal-dialog.modal-open.modal-small .modal-dialog-wrap {
max-width: 368px;
}
}
4. Keyboard Support
Keyboard navigation is essential for accessibility. Our implementation supports:
- Escape key to close the modal
- Tab key to navigate through focusable elements
- Shift+Tab for reverse navigation
- Return/Enter key on trigger elements
5. Background Interaction Prevention
When a modal is open, users shouldn’t be able to interact with content behind it. We achieve this through:
.modal-cover {
background: #121212;
opacity: 0;
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
transition: opacity 0.25s ease;
visibility: hidden;
}
.modal-cover.modal-cover-open {
opacity: 0.75;
visibility: visible;
z-index: 100;
}
body.modal-open {
overflow: hidden;
}
Enhanced Features for Media Content
Our solution includes special handling for video content, particularly YouTube embeds:
<div id="videoModal" class="modal-dialog modal-large">
<div class="modal-dialog-wrap">
<div class="modal-dialog-content">
<iframe src="" data-src="https://www.youtube.com/embed/VIDEO_ID?rel=0&autoplay=1"
allowfullscreen="allowfullscreen">
</iframe>
</div>
<button class="modal-close" aria-label="Close modal">×</button>
</div>
</div>
The video loading is deferred until the modal opens, improving page load performance.
Best Practices and Tips
- Always Provide a Close Button
- Make it visible and clickable
- Include proper aria-label
- Position consistently across all modals
- Handle Multiple Screen Sizes
- Test on various devices
- Ensure content remains readable
- Consider different interaction methods
- Manage Focus States
- Visible focus indicators
- Logical tab order
- No focus traps or dead ends
- Performance Considerations
- Lazy load modal content when possible
- Use CSS transitions for smooth animations
- Minimize layout shifts when opening/closing
Conclusion
Building accessible modals requires attention to detail and understanding of various user needs. While it might seem like extra work initially, the benefits of creating an inclusive user experience far outweigh the development effort. This implementation provides a solid foundation that you can build upon for your specific needs.
Remember: accessibility isn’t just about compliance—it’s about creating a better experience for everyone who uses your website.
Resources
Check out the full source code and documentation on GitHub or on Codepen.
Live example:
Example modal in three sizes:
Large Medium SmallExample modal with no header:
Modal no headerExample modal with no header 2:
Modal no header 2Example modal with header focus:
Modal with header focusExample of video
test video 1test video 2
regular test
Title of modal with header focus
Description for modal with header focus
masm.ca video intro
masm.ca alternate video intro
Code samples
HTML:
<!-- Start content -->
<p>Example modal in three sizes:</p>
<a href="#" role="button" class="modal-trigger" data-reveal-id="largemodal">Large</a>
<a href="#" role="button" class="modal-trigger" data-reveal-id="mediummodal">Medium</a>
<ahref="#"role="button"class="modal-trigger"data-reveal-id="smallmodal">Small</a>
<p>Example modal with no header:</p>
<ahref="#"role="button"class="modal-trigger"data-reveal-id="modalnoheader">Modal no header</a>
<p>Example modal with no header 2:</p>
<ahref="#"role="button"class="modal-trigger"data-reveal-id="modalnoheader2">Modal no header 2</a>
<p>Example modal with header focus:</p>
<ahref="#"role="button"class="modal-trigger"data-reveal-id="modalheaderfocus">Modal with header focus</a>
<p>Example of video</p>
<a href="#" data-reveal-id="masm_video_example_1" class="modal-trigger video-trigger">test video 1</a><br/>
<a href="#" data-reveal-id="masm_video_example_2" class="modal-trigger video-trigger">test video 2</a>
<!-- Start modals -->
<divclass="modal-cover"></div>
<div id="largemodal" class="modal-dialog modal-large" role="dialog" aria-modal="true" aria-labelledby="title1">
<div class="modal-dialog-wrap">
<div class="modal-dialog-content">
<h2 id="title1" tabindex="-1" class="dialog-label">Title of the large modal</h2>
<p>Large modal description</p>
<a href="#" title="example link for large modal">example link for large modal</a>
</div>
<button class="modal-close" aria-label="Close modal">×</button>
</div>
</div>
<div id="mediummodal" class="modal-dialog modal-medium" role="dialog" aria-modal="true" aria-labelledby="title2">
<div class="modal-dialog-wrap">
<div class="modal-dialog-content">
<h2 id="title2" tabindex="-1" class="dialog-label">Title of the medium modal</h2>
<p>Medium modal description</p>
<a href="#" title="example link for medium modal">example link for medium modal</a>
</div>
<button class="modal-close" aria-label="Close modal">×</button>
</div>
</div>
<div id="smallmodal" class="modal-dialog modal-small" role="dialog" aria-modal="true" aria-labelledby="title3">
<div class="modal-dialog-wrap">
<div class="modal-dialog-content">
<h2 id="title3" tabindex="-1" class="dialog-label">Title of the small modal</h2>
<p>Small modal description</p>
<a href="#" title="example link for medium modal">example link for small modal</a>
</div>
<button class="modal-close" aria-label="Close modal">×</button>
</div>
</div>
<div id="modalnoheader" class="modal-dialog modal-large" role="dialog" aria-modal="true" aria-label="Modal example with no header title">
<div class="modal-dialog-wrap">
<div class="modal-dialog-content">
<a href="#" title="link 1">link 1</a><br/>
<a href="#" title="link 2">link 2</a>
</div>
<buttonclass="modal-close"aria-label="Close modal">×</button>
</div>
</div>
<div id="modalnoheader2" class="modal-dialog modal-large" role="dialog" aria-modal="true" aria-label="Modal example with no header title">
<div class="modal-dialog-wrap">
<div class="modal-dialog-content">
<p tabindex="-1">regular test</p>
</div>
<buttonclass="modal-close"aria-label="Close modal">×</button>
</div>
</div>
<div id="modalheaderfocus" class="modal-dialog modal-large" role="dialog" aria-modal="true" aria-labelledby="title4">
<div class="modal-dialog-wrap">
<div class="modal-dialog-content">
<h2 id="title4" tabindex="-1" class="dialog-label">Title of modal with header focus</h2>
<p>Description for modal with header focus</p>
</div>
<button class="modal-close" aria-label="Close modal">×</button>
</div>
</div>
<!-- Start video modals-->
<divid="masm_video_example_1"class="modal-dialog modal-large"role="dialog"aria-modal="true"aria-labelledby="canada150story3">
<divclass="modal-dialog-wrap">
<div class="modal-dialog-content">
<h2 class="sr-only" tabindex="-1">masm.ca video intro</h2>
<iframe src="" data-src="https://www.youtube.com/embed/XK8-5sf98xI?rel=0&autoplay=1" width="100%" height="531" frameborder="0" allowfullscreen="allowfullscreen"></iframe>
</div>
<buttonclass="modal-close"aria-label="Close modal">×</button>
</div>
</div>
<divid="masm_video_example_2"class="modal-dialog modal-large"role="dialog"aria-modal="true"aria-labelledby="canada150story2">
<divclass="modal-dialog-wrap">
<div class="modal-dialog-content">
<h2 class="sr-only" tabindex="-1">masm.ca alternate video intro</h2>
<iframe src="" data-src="https://www.youtube.com/embed/eS91NrO8fqA?rel=0&autoplay=1" width="100%" height="531" frameborder="0" allowfullscreen="allowfullscreen"></iframe>
</div>
<buttonclass="modal-close"aria-label="Close modal">×</button>
</div>
</div>
<script>
// <![CDATA[
// load video from data-src attribute on trigger
let videotriggers = document.querySelectorAll('.modal-trigger.video-trigger');
videotriggers.forEach(function(videotrigger, index) {
let videoidattribute = videotrigger.getAttribute('data-reveal-id');
let videoelement = document.querySelector('#'+videoidattribute);
let videoiframeupdate = videoelement.querySelector('iframe');
let videoiframedatasrc = videoiframeupdate.getAttribute('data-src');
videoiframeupdate.setAttribute('allow', 'autoplay; fullscreen');
videoiframeupdate.setAttribute('allowtransparency', 'true');
videotrigger.addEventListener('click', function(e) {
videoiframeupdate.setAttribute('src', videoiframedatasrc);
});
});
// ]]></script>
<!-- End content -->
CSS:
/* Start modal solution */ .sr-only { visibility: hidden; height: 0px; margin: 0; } body.body-modal-open { overflow: hidden; } .modal-trigger a:focus, .modal-trigger span:focus { outline: 2px solid black; } .hiddenspanstart { width: 1px; height: 1px; display: block; position: absolute; outline: none !important; } .modal-cover { background: #121212; opacity: 0; position: fixed; left: 0; top: 0; right: 0; bottom: 0; display: block; transition: opacity 0.25s ease; visibility: hidden; } .dialog-label { outline: none; } .modal-cover.modal-cover-open { display: block; opacity: 0.75; visibility: visible; z-index: 100; } .modal-dialog { position: fixed; top: 0; left: 0; right: 0; bottom: 0; margin: auto; border: 0; opacity: 0; transition: opacity 0.25s ease; visibility: hidden; height: fit-content; z-index: 999; overflow-y: auto; max-height: 100%; display: block; } .modal-dialog .modal-dialog-wrap { margin-left: auto; margin-right: auto; position: relative; } .modal-dialog.modal-open { align-items: center; opacity: 1; visibility: visible; margin-left: 1rem; margin-right: 1rem; max-width: 100%; } @media only screen and (min-width: 40.0625em) { .modal-dialog.modal-open { margin-left: 3.25rem; margin-right: 3.25rem; } } @media only screen and (min-width: 64.0625em) { .modal-dialog.modal-open { margin-left: auto; margin-right: auto; } } @media only screen and (min-width: 64.0625em) { .modal-dialog.modal-open.modal-large .modal-dialog-wrap { max-width: 778px; } } @media only screen and (min-width: 64.0625em) { .modal-dialog.modal-open.modal-large-video .modal-dialog-wrap { max-width: 1440px; width: 80vw; } } @media only screen and (min-width: 64.0625em) { .modal-dialog.modal-open.modal-medium .modal-dialog-wrap { max-width: 568px; } } @media only screen and (min-width: 64.0625em) { .modal-dialog.modal-open.modal-small .modal-dialog-wrap { max-width: 368px; } } .modal-dialog-content { background: white; padding: 3em; position: relative; border-radius: 4px; } .modal-dialog-content iframe { width: 100%; height: 45vw; } @media only screen and (min-width: 64.0625em) { .modal-dialog-content iframe { height: 384px; } } .modal-dialog-content h2, .modal-dialog-content h3 { margin-top: 0px; } .modal-dialog-content h2:focus-visible, .modal-dialog-content h2:focus, .modal-dialog-content h3:focus-visible, .modal-dialog-content h3:focus { outline: none; } .modal-close { position: absolute; top: 1.0625rem; right: 1.0625rem; width: 1rem; height: 1rem; z-index: 210; text-indent: -9999px; background: url(https://www.canadapost-postescanada.ca/cpc/assets/cpc/img/icons/Cancel_mobile.svg) 50% 50% no-repeat transparent !important; border: none; outline-offset: 3px; min-width: unset; padding: 0; margin: 0; cursor: pointer; } /* End campaign library modal solution */
JavaScript:
document.addEventListener("DOMContentLoaded", function(event) {
let clmodalcover = document.querySelector('.modal-cover');
let clmodaltriggers = document.querySelectorAll('.modal-trigger');
// For each trigger loop
clmodaltriggers.forEach(function(clmodaltrigger, index) {
let clmodal = document.querySelectorAll('.modal-dialog')[index];
let clmodalclose = document.querySelectorAll('.modal-close')[index];
clmodaltrigger.addEventListener('keypress', function(e) {
if(e.keyCode == 32) {
clmodaltrigger.click();
e.preventDefault();
}
});
// Modal trigger event
clmodaltrigger.addEventListener('click', function(e) {
let clmodalid = clmodaltrigger.getAttribute('data-reveal-id');
clmodal = document.querySelector('#'+clmodalid);
clmodalclose = clmodal.querySelector('.modal-close');
if(document.querySelector('html').getAttribute('lang') == 'fr') { // sets French aria label on close button when opening modal if html lang attribute equals 'fr'
clmodalclose.setAttribute('aria-label', 'Fermer');
}
function closemodalactions() {
clmodal.classList.remove('modal-open');
clmodalcover.classList.remove('modal-cover-open');
document.body.classList.remove("modal-open");
document.body.classList.remove('overflow-hidden');
// if there is iframe reset video on close when video is playing
if (clmodal.querySelector("iframe")) {
let testiframe = clmodal.querySelector("iframe");
testiframe.setAttribute('src', ' ');
}
clmodaltrigger.focus();
}
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
closemodalactions();
}
});
document.body.classList.add('overflow-hidden');
// Event listener for escape key
clmodal.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
closemodalactions();
}
});
// Event listener for close button
clmodalclose.addEventListener('click', function(e) {
closemodalactions();
});
// Event listener click outside left or right of modal listener
let modalcontentcheckclick = clmodal.querySelector('.modal-dialog-content');
clmodal.addEventListener('click', event => {
const isClickInside = modalcontentcheckclick.contains(event.target);
if (!isClickInside) {
closemodalactions();
}
})
// Event listener click outside top or bottom of modal listener
clmodalcover.addEventListener('click', event => {
const isClickInside = clmodal.contains(event.target);
if (!isClickInside) {
closemodalactions();
}
})
// check to see if iframe exists and if so append tabindex span
let iframecheck = clmodal.getElementsByTagName('iframe');
let modalcontent = clmodal.querySelector('.modal-dialog-content');
if (iframecheck.length != 0) {
if (!modalcontent.querySelector('.hiddenspanstart')) {
let iframespan = document.createElement("span");
iframespan.classList.add('hiddenspanstart');
iframespan.setAttribute("tabindex", "0");
modalcontent.prepend(iframespan);
}
}
clmodal.classList.add('modal-open');
clmodalcover.classList.add('modal-cover-open');
document.body.classList.add("modal-open");
document.body.classList.add('overflow-hidden');
clmodalkeyboardtrap(clmodal);
e.preventDefault();
});
});
document.querySelector('.modal-dialog-wrap').addEventListener('click', function(e) {
if (e.target.nodeName != "LABEL" && e.target.nodeName != "INPUT" && e.target.nodeName != "BUTTON" ) {
document.querySelector('.q1 .question-wrapper h2').focus();
}
});
function clmodalkeyboardtrap(clmodal) {
// focusable elements
const focusableElements =
'a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), object, embed, [tabindex="0"], [contenteditable]';
const modal = clmodal; // select the modal by it's selector
const firstFocusableElement = modal.querySelectorAll(focusableElements)[0]; // get first element to be focused inside modal
let modalfocus = modal.querySelector('[tabindex="-1"]');
// checks span tag on load and shift focus to close
const hidespan = modal.querySelector('.hiddenspanstart');
const iframecheck = clmodal.getElementsByTagName('iframe');
const focusableContent = modal.querySelectorAll(focusableElements);
const lastFocusableElement = focusableContent[focusableContent.length - 1]; // get last element to be focused inside modal
document.addEventListener('keydown', function(e) {
let isTabPressed = e.key === 'Tab' || e.keyCode === 9;
if (!isTabPressed) {
return;
}
if (e.shiftKey) { // if shift key pressed for shift + tab combination
if (document.activeElement === firstFocusableElement) {
lastFocusableElement.focus(); // add focus for the last focusable element
e.preventDefault();
}
} else { // if tab key is pressed
if (document.activeElement === lastFocusableElement) { // if focused has reached to last focusable element then focus first focusable element after pressing tab
if (iframecheck.length != 0) {
iframecheck[0].focus();
} else {
firstFocusableElement.focus(); // add focus for the first focusable element
}
e.preventDefault();
}
}
});
let modalfocuscheck = modal.querySelectorAll('[tabindex="-1"]');
if (modalfocuscheck.length != 0) {
modalfocus.focus();
modalfocus.addEventListener('keydown', function(e) {
let isTabPressed = e.key === 'Tab' || e.keyCode === 9;
if (!isTabPressed) {
return;
}
if (e.shiftKey) {
firstFocusableElement.focus();
}
});
} else if (modal.contains(hidespan)) {
iframecheck[0].focus();
} else {
firstFocusableElement.focus();
}
}
});