Creating Accessible HTML5 Modal Dialogs For Desktop and Mobile

March 17th, 2019 by zoltan · No Comments
The button below is accessible with a mouse, a keyboard and accessibility gestures on a mobile device. The blog post below explains why.

Login

In order to continue, please log into the application

Accessible modals aren’t hard to make, and you can make your modal dialogs accessible if you keep in mind the following requirements:

  1. Markup: You should use the correct HTML5 tags and ARIA roles inside the modal to ensure it works well with screen readers.
  2. Applying Focus On Modal Open: When an interactive element (like a button) opens a modal, keyboard focus should go to the first element in the modal (usually the modal’s close button). The modal should also announce that a modal opened along with the modal’s title.
  3. Applying Focus On Modal Close: When the user closes the modal, keyboard focus should go back to the interactive element that opened up the element in step 1.
  4. Restricting Focus To The Open Modal: When the modal is open, users should only be able to access elements within that modal. This means that keyboard users should not be able to tab into elements behind the modal, and that mobile users should not be able to swipe beneath the modal.

Below is a video demonstrating of all of these features in action, on both a desktop and mobile device:

Transcript for Video Demo

(Note: This transcript will mention when code snippets are shown. These snippets are fully outlined in the blog post in great detail) (Scene is a title screen, which reads "Creating Accessible HTML5 Modal Dialogs For Desktop and Mobile. Zoltan Hawryluk - useragentman.com") Narrator: “In this video, I will be demonstrating how to create an accessible modal dialog using the HTML5 tag. It uses a polyfill for browsers that don't support it yet natively. For details on the polyfill and more information on how this demo was built, please checkout the video's accompanying blog post on useragentman.com.” (A web page appears inside the OSX Safari web browser. The web page has a picture of a woman and a gnome looking at an HTML button labelled "Log in to our website") The first browser we are going to demonstrate is Safari using VoiceOver, which is built into OSX. Right now, keyboard focus is on the link just before the button labelled "Log in to our website", which will open the modal when pressed. So, let's use the keyboard to navigate to the button...” (As the narrator uses his keyboard to navigate to the "Log in to our website" button, the VoiceOver screen reader reports what items are focused.) VoiceOver: “Log in to our website, button, main, two items” Narrator: “and now we'll hit the enter key.” (Narrator hits the enter key. A dialog opens up with a log in form in it. The web browser automatically places focus on the dialog's "close" button.) VoiceOver: “Close this dialog, web dialog, login, 1 item. In order to continue, please log into the application.” (A close up appears on VoiceOver's caption panel to emphasize what the narrator says below.) Narrator: “You may have noticed that when the dialog opened, the close button gains focus. Also, note that the screen reader said "close this dialog, button". This tells the screen reader user that focus is on a button labelled "close this dialog".” (Code snippets appear to emphasize the points outlined by the narrator below.) Narrator: “Incidentally, this label was coded as alt attribute for the button's tag. You'll also note that the screen reader reports it is a web dialog. That's because the button is inside a HTML dialog tag. VoiceOver would also announce this if the modal was coded as a div tag with its role attribute set to "dialog". In fact, setting the role to dialog is what the polyfill does on all dialog tags so screen readers can report the dialog properly to the user.” Finally, you will notice the screen reader reads out the header, and the description in the dialog. This happens because the tag has its aria-labelledby attribute set to the id of the header, and its aria-describedby attribute set the id of the description text.” (Scene changes from the code snippets back to the web page.) Narrator: “Now, let's find out what happens when we tab past the end of the dialog.” (The narrator uses the keyboard to tab to the next interactive element) VoiceOver: “Username, edit text with autofill menu.” (The narrator uses the keyboard to tab to the next interactive element) VoiceOver: “Password, secure edit text with...” (The narrator uses the keyboard to tab to the next interactive element) VoiceOver: “Cancel, button.” (The narrator uses the keyboard to tab to the next interactive element) VoiceOver: “Confirm, button.” (The narrator uses the keyboard to tab to the next interactive element. Since there is no interactive element after the confirm button, focus goes back to the dialog's close button) VoiceOver: “Close this dialog, button.” Narrator: “You'll notice that when I hit the tab key after the "Confirm" button, focus goes to the first element in the dialog. Focus never goes to any interactive element behind the modal. For browsers that support the tag, this automatically happens because it is native browser behavior. However, for browsers that don't, the polyfill steps in and uses focus events to implement this. If focus goes outside the dialog, it loops back to the first element of the modal... if we are tabbing forward, of course. If we are tabbing backward, using a shift-TAB, then focus goes the last element of the modal. And now, let's find out what happens when I close the modal...” (The narrator hits the enter key in order to activate the close button. Focus goes back the button that originally opened the modal.) VoiceOver: “Login in to our website, button, main, 2 items.” Narrator: “You'll notice that focus is placed back to the button that opened it. Surprisingly, this is not default browser behavior, even though this is considered best practice in the WAI-ARIA documentation. It is added by a bit of code in the polyfill. This code is executed for all browsers, including those with native support for the dialog tag. Now let's look at Google Chrome using Android's built in screen reader, Talkback.” (Scene changes from Safari on OSX to Google Chrome on Android, showing the same web page.) Narrator: “I am using a Samsung tablet for this demonstration, but the behavior for this device is, more or less, the same on any Android device using this software. I'm going to start by navigating to the link just before the button that opens the modal. Since this device doesn't have a keyboard, Talkback users must swipe left and right to navigate back and forth within the document.” (Narrator swipes through the document to get to the "Log in to our website" button. Focus seems to go to not just the interactive elements on the page, but on all elements on the page. Talkback says short beginnings of dialog while user swipes through text. Finally, focus gets to the button.) Talkback: “Log in to our website, button, out of list.” Narrator: “You'll also note that it looks like focus is not just going to the interactive elements on the page, like the tab key does on a keyboard enabled device. Instead, Talkback's so-called "accessibility focus" goes to all the items on the page that can be read. This is default behavior for Talkback, and iOS's VoiceOver screen reader has similar behavior. This can be changed, but I will leave that for a future video. Now let's double tap to activate this button.” (Narrator double taps the screen) Talkback: “Close this dialog, button. Double-tap to activate.” Narrator: “Again, you will notice that focus goes to the close button. However, unlike VoiceOver, Talkback didn't read the dialog's heading or the description. It also doesn't mention that you are now inside a web dialog. This is because different screen readers don't support all the ARIA-attributes equally. This is the reason why I chose "close this dialog" as the alt attribute for the close button image. It tells the user that they are inside a dialog, even if a particular screen reader doesn't support the dialog role. This is a good example that, just like in all aspects of web development, you should use progressive-enhancement to make your code bullet-proof. Now, let's try to swipe backwards past the close button at the beginning of the dialog.” (Narrator tries to swipe backwards part the close button. Focus goes to the whole document in the web browser.) Talkback: “Creating An Accessible Dialog Using the HTML5 Dialog Tag, web view.” Narrator: “You'll notice that focus doesn't go behind the modal, just like when I was using a desktop screen reader. However, this is not because of the focus events I described earlier. The problem with most (if not all) mobile screen readers is that they don't fire focus and blur events back to the browser when swiping over the document.” (Scene changes to a web browser development tools, which shows aria-hidden="true" being set on HTML elements outside of the dialog HTML element.) Narrator: “In order to code this behavior on mobile, developers must set aria-hidden= "true" to all elements outside of the modal dialog. This may seem a little scary if you have a lot of interactive elements outside the dialog. (I mean, who wants to set aria-hidden attributes on all of those DOM nodes?) However, this is an efficient algorithm to do this, and it's in the blog post that accompanies this video.” (Scene changes to the useragentman.com website) Narrator: “If you are playing this video on the YouTube website, the link to the post is the description below it.” (Scene changes to a picture of the narrator, Zoltan Hawryluk, which shows his twitter handle, @zoltandulac, and his blog's URL, useragentman.com) Narrator: “Hope this demonstration was helpful! Please feel free to comment on the my YouTube page, or my blog, useragentman.com. I'd love to hear your feedback. Thank you.”

Unfortunately, some “accessible” modal JavaScript libraries work well for keyboard users, but not for mobile ones. This article will discuss why and how to work around the issues that mobile creates.

In order to keep this tutorial simple, we will be using the HTML5 <dialog> polyfill. We will be adding code to make not only the polyfill accessible, but also improve the accessibility of native implementations (which, at the time of this writing, is only Blink-based browsers like Chrome and Opera).

If you want to just download the example code used in this article, you can download it from the demo’s github repo and start playing with it right away.

Download the demo code from github
Go to a cleanroom page of the demo

Basic Markup

Creating a dialog is simple.



<button id="updateDetails">
    Open Modal
</button>

.
.
.

<!-- Simple pop-up dialog box, containing a form -->
<dialog 
    id="favDialog"
    aria-labelledby="favDialog__label"
    aria-describedby="favDialog__description">
    <div role="document">

        <!--
            The cancel button should be the first in the DOM.
            Note it should be a button, not a link, since it
            doesn't go to another page.
        -->
        <button
            id="cancel"
            class="a11y-modal__button--close"
        >
            <img
                class="a11y-modal__button--close-image"
                src="images/close-window.svg"
                alt="close this dialog">
        </button>

        <!--
            This is the title of the dialog.  Since it is
            set as the dialog's `aria-labelledby`, it will 
            be read when it is opened
        -->
        <h2 id="favDialog__label">Login</h2>

        <!--
            This should a more detailed description of the
            purpose of the dialog.  Since it is set as the 
            dialog's `aria-describedby`, it will be read
            when the dialog is opened.
        -->
        <p id="favDialog__description">
           In order to continue, please log into the application
        </p>
        
        <!-- 
            Rest of dialog here 
        -->

        ...
    </div>
</dialog>

.
.
.

<script src="js/shared/dialog-polyfill.js"></script>


In order for the button to actually open the modal, we will need a little bit of Javascript:

(function() {
    // Button that opens the dialog
    const updateButton = document.getElementById('updateDetails');

    // Clicking this button opens the dialog
    updateButton.addEventListener('click', function() {
      favDialog.showModal();
    });

    // The modal's cancel button
    const cancelButton = document.getElementById('cancel');

    // Clicking the cancel button will close the dialog
    cancelButton.addEventListener('click', function() {
      favDialog.close();
    });

    // The <dialog> element itself
    const favDialog = document.getElementById('favDialog');

    // If we are using the polyfill, then initialize it
    if (window.dialogPolyfill) {
      dialogPolyfill.registerDialog(favDialog);
    }

  })();

A few accessibility notes here:

  1. Using the <dialog> tag, screen readers will report the element as a dialog. The polyfill will insert a role="dialog" inside the dialog tag for browsers that don’t support <dialog> natively. If you decide to not to use the HTML5 <dialog> tag to create the modal, you must add the role="dialog" attribute yourself in order to maintain this accessibility feature.
  2. The <div role="document"> (that is contained in the <dialog> code above lets screen readers know the dialog contains a separate document inside of it and will read things appropriately to the user. More inforamation can be found on Mozilla’s documentation on the document role.

Applying Focus on Modal Open

The WAI-ARIA spec states that “When first opened, focus should be set to the first focusable element within the dialog”. In a lot of cases, this would be the close button, which is usually an X at the top right of the modal itself, since it is the top-most element (which is nice, since if a user opens the modal by accident, they can easily close it immediately by pressing Enter on the keyboard).

Native implementations of the HTML5 <dialog> element (as well the polyfill) will do this for free. If you are using any other implementation, you must implement this yourself using the JavaScript focus() method.

Applying Focus On Modal Close

Best practice states that when a modal dialog is closed, focus should go back the element that opened it. This is something that doesn’t happen in both the current native implementations of <dialog>, nor in the polyfill. However, this gist adds this functionality to both, so I added it to my version of the code.

Limiting Focus To An Open Modal

Native implementations of the HTML5 <dialog> element will do this for free, due to it being part of the spec. The polyfill partially implements this, so I have included notes on where it fails and how to fix the issues.

In many cases, developers will implement focus state management for desktop devices, but not for mobile ones. You’d think you wouldn’t need to worry about two different ways to do this, but its the way the world is at the moment, so let’s talk about how to deal with it.

Desktop

For desktop devices, keyboard focus must never go outside the modal. Native HTML5 <dialog> implementations, as well as the polyfill, do this for free.

If you are using some other modal dialog framework, you must implement this yourself if the framework doesn’t. One way to do this is using my own accessibility.js library’s setKeepFocusInside(). More information is available on the accessibility.js GitHub repo.

It is also possible to do this with the proposed HTML5 inert attribute. As of this writing, no browser supports this attribute, but there is a inert polyfill made by Google as well as one by WICG. I have not used either of these yet, so YMMV (Your mileage may vary).

Mobile

This is the part that usually trips a lot of developers. As a matter of fact, many commonly used modal frameworks, like Bootstrap’s Modal Dialog, limit focus correctly on dekstop devices but not on mobile ones. This is because blur and focus events, which are commonly used to limiting focus to the modal on desktop, don’t fire on mobile devices when using a screen-reader like iOS’s Voiceover or Android’s Talkback. Mobile screenreaders have an idea of accessibility focus, which is not the same as keyboard focus. Keyboard focus (on desktop computers) allows focus to occur on interactive elements (links, buttons, form elements, and DOM elements with `tabindex` that is anything other than -1. Accessible focus on mobile devices, on the other hand, allows “focus” on any element on a page, including those which have a tabindex of -1.

So what is a user to do? Rahul Kumar mentions in his Medium article Focus Trapping for Accessibility (A11Y) that developers can ensure accessibility focus will not be applied on elements outside the modal by setting their aria-hidden attribute to "true". Since he handwaves how how this should work in a general sense, I had to work out this pseudo-code to do this:

Picture of a whiteboard with pseudocode on it written in calligraphy.

My sketch of the algorithm that is needed to ensure focus stays in modals in mobile devices.

I then translated this into JavaScript

/**
 * This ensures that a mobile devices "accessibilityFocus"
 * (which is independant from a browser focus) cannot
 * go outside an element, by ensuring
 * the least amount of nodes outside the modal are
 * marked with aria-hidden="true".
 *
 * @param {HTMLElement} el - the element that will have the loop.
 */
function setMobileFocusLoop(el) {
  const { body } = document;
  let currentEl = el;

  do {
    // for every sibling of currentElement, we mark with
    // aria-hidden="true".
    const siblings = currentEl.parentNode.childNodes;
    for (let i = 0; i < siblings.length; i++) {
      const sibling = siblings[i];
      if (sibling !== currentEl && sibling.setAttribute) {
        sibling.setAttribute('data-old-aria-hidden', sibling.ariaHidden || 'null');
        sibling.setAttribute('aria-hidden', 'true');
      }
    }

    // we then set the currentEl to be the parent node
    // and repeat (unless the currentNode is the body tag).
    currentEl = currentEl.parentNode;
  } while (currentEl !== body);
}

Note that I set the data-old-aria-hidden attribute to the previous value of the aria-hidden attribute (just in case it was set to something before this function was executed). This data- attribute is also used when undoing all of this when the modal is closed:

/**
 * reset all the nodes that have been marked as aria-hidden="true"
 * in the setMobileFocusLoop() method back to their original
 * aria-hidden values.
 */
function removeMobileFocusLoop() {
  const elsToReset = document.querySelectorAll('["data-old-aria-hidden"]');

  for (let i = 0; i < elsToReset.length; i++) {
    const el = elsToReset[i];
    const ariaHiddenVal = el.getAttribute('data-old-aria-hidden');
    if (ariaHiddenVal === 'null') {
      el.removeAttribute('aria-hidden');
    } else {
      el.setAttribute('aria-hidden', ariaHiddenVal);
    }
    el.removeAttribute('data-old-aria-hidden');
  }
}

Note that there is a (much easier) alternative to doing this: if you set aria-modal="true" on the dialog element, user agents are supposed to restrict focus to the it. Unfortunately, support for aria-modal is spotty (if supported at all), so for now, it is important to use the above solution until support reaches critical mass.

What Can I Use To Make My Existing Modal Dialog Code Accessible

Even though I made only one particular modal library accessible, the thought process of how to do so is written here, you just have to implement it in the one you are using! A lot of the heavy lifting for my solution was incorporated in accessibility.js, so feel free to use it, or just steal the parts of the code you need from it.

If you have made a modal library accessible using the information here, please feel free to share your work in the comments below. I would love to see if this blog post has helped anyone.

Acknowledgments

Tags: accessibility · dialog · HTML5 · modal role · Uncategorized · , , , , , , , ,

0 responses so far ↓
Give Feedback

Don't be shy! Give feedback and join the discussion.

Please Note: If you are asking for help using the information on this page or if you are reporting a bug with the code featured here, please include a URL that shows the problem you are experiencing along with the browser/version number/operating system combination where the issue manifests itself. Without this information, I may not be able to respond.

An orange star denotes a required field.