Accessible Modal Dialogs

Accessible modals banner

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">&times;</button>
</div>
</div>

The video loading is deferred until the modal opens, improving page load performance.

Best Practices and Tips

  1. Always Provide a Close Button
    • Make it visible and clickable
    • Include proper aria-label
    • Position consistently across all modals
  2. Handle Multiple Screen Sizes
    • Test on various devices
    • Ensure content remains readable
    • Consider different interaction methods
  3. Manage Focus States
    • Visible focus indicators
    • Logical tab order
    • No focus traps or dead ends
  4. 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    Small

Example modal with no header:

Modal no header

Example modal with no header 2:

Modal no header 2

Example modal with header focus:

Modal with header focus

Example of video

test video 1
test video 2

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> &nbsp;&nbsp; 
 <a href="#" role="button" class="modal-trigger" data-reveal-id="mediummodal">Medium</a>  &nbsp;&nbsp; 
 <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">&times;</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">&times;</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">&times;</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">&times;</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">&times;</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">&times;</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&amp;autoplay=1" width="100%" height="531" frameborder="0" allowfullscreen="allowfullscreen"></iframe> 
  </div> 
  <buttonclass="modal-close"aria-label="Close modal">&times;</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&amp;autoplay=1" width="100%" height="531" frameborder="0" allowfullscreen="allowfullscreen"></iframe> 
    </div> 
    <buttonclass="modal-close"aria-label="Close modal">&times;</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(); 
       } 
     } 
   });