Modal Dialogs are a tricky thing to make accessible. For visual users navigating with a mouse, creating a dialog is as simple as styling the element to look visually different from the rest of the page. However, users navigating a site via a keyboard and/or screenreader need a lot more.

Luckily, the WAI-ARIA Specification provides some guidelines on how to build an accessible modal dialog. Their guidelines can be broken up into size parts -

  1. Markup the Dialog and Dialog Overlay Appropriately
  2. On Dialog Open, Set Focus
  3. On Dialog Close, Return Focus to the Last Focused Element
  4. While Open, Prevent Mouse Clicks Outside the Dialog
  5. While Open, Prevent Tabbing to Outside the Dialog
  6. While Open?
  7. Allow the ESC Key to Close the Dialog

1. Markup the Dialog and Dialog Overlay Appropriately

The Dialog

Dialogs should have an appropriate role and label

When creating a dialog, we need to make sure that it has the appropriate role, in this case dialog. We also need to make sure that there is a label for the dialog, provided using the aria-labelledby and optionally the aria-describedby attributes (see HTML for Screen Readers).

Neither of the labels need to be visually displayed on the screen, as long as they are in the document tree.

<div class="dialog" role="dialog" aria-labelledby="dialog-title" aria-describedby="dialog-description">  
    <h1 id="dialog-title">Site Navigation</h1>
    <p id="dialog-description" class="sr-only">Description goes here</p>
    <nav>
        <ul>
            <li><a href="one.html">Link One</a></li>
            <li><a href="two.html">Link Two</a></li>
            <li><a href="three.html">Link Three</a></li>
        </ul>
    </nav>
    <button type="button" aria-label="Close Navigation" class="close-dialog"> <i class="fa fa-times"></i> </button>
</div>  

The Overlay

The dialog overlay should be a separate element, preferably a child of the <body> element (we will see why in a following section). It does not require any special ARIA attributes.

<body>  
    <div class=“body-content”></div>
    <div class=“dialog”></div>
    <div class=“dialog-overlay”></div>
<body>  

2. On Dialog Open, Set Focus

When first opened, focus should be set to the first focusable element within the dialog

To achieve this, we need to generate an array of all the focusable elements within the dialog. Elements that can receive focus are anchors, form input elements, buttons, and any element with a tabindex of 0 or greater. We can get the array by querying the document for all of these types of elements -

function Dialog(dialogEl, overlayEl) {

    this.dialogEl = dialogEl;

    var focusableEls = this.dialogEl.querySelectorAll('a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex="0"]');
    this.focusableEls = Array.prototype.slice.call(focusableEls);
}

We can then get the first of these focusable elements and use the focus() method to direct focus to it.

this.firstFocusableEl = focusableEls[0];  
this.firstFocusableEl.focus();  

There should be always at least one focusable element in the dialog. If there are no focusable elements within the content of the dialog, there should still be the button that closes the dialog itself.

3. On Dialog Close, Return Focus to Last Focused Element

When the dialog is closed, focus should be returned to the element that opened it

When the dialog is first opened, we can determine which element opened it by checking the active element in the document (before we redirect focus away).

Dialog.prototype.open = function() {  
    this.focusedElBeforeOpen = document.activeElement;
}

When the dialog is then closed, we can easily set that element to focus.

Dialog.prototype.close = function() {  
    this.focusedElBeforeOpen.focus();
};

4. While Open, Prevent Mouse Clicks Outside the Dialog

Users should not be able to click on elements outside the dialog window

We can achieve this with some simple styling of the dialog overlay element. First, we position the overlay on the z-axis in-between the dialog itself and the rest of the body content -

.dialog { z-index: 3; }
.dialog-overlay { z-index: 2; }
.body-content { z-index: 1; }

This is where it helps that these three elements are directly children of the <body>. Next, we need to make sure that the overlay takes up the entire viewport -

.dialog-overlay {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background-color: rgba(0,0,0,0.7);
}

Finally, in our HTML, we set a tabindex of -1 on the overlay to prevent it from receiving focus.

<body>  
    <div class=“body-content”></div>
    <div class=“dialog”></div>
    <div class=“dialog-overlay” tabindex="-1"></div>
<body>  

5. While Open, Prevent Tabbing to Outside the Dialog

User navigating with a keyboard should not be able toTAB out of the dialog content

Although keyboard traps are typically discouraged, modal dialogs are one scenario in which they are necessary. Because a modal visually constrains the user to the dialog window, keyboard users should have the same experience.

To achieve this, we need to intercept two situations -

  1. If the user is tabbing forward (pressing the TAB key alone) from the last focusable element, then we need to move them to the first focusable element.
  2. If the user is tabbing backward (pressing the SHIFT and TAB keys) from the first focusable element, then we need to move them to the last focusable element.

If there is only one focusable element, then just prevent any behaviour from occurring when the user presses the TAB key.

Dialog.prototype.handleKeyDown = function(e) {

    var Dialog = this;
    var KEY_TAB = 9;

    function handleBackwardTab() {
        if ( document.activeElement === Dialog.firstFocusableEl ) {
            e.preventDefault();
            Dialog.lastFocusableEl.focus();
        }
    }
    function handleForwardTab() {
        if ( document.activeElement === Dialog.lastFocusableEl ) {
            e.preventDefault();
            Dialog.firstFocusableEl.focus();
        }
    }

    switch(e.keyCode) {
        case KEY_TAB:
            if ( Dialog.focusableEls.length === 1 ) {
                e.preventDefault();
                break;
            } 

            if ( e.shiftKey ) {
                handleBackwardTab();
            } else {
                handleForwardTab();
            }

            break;
        default:
            break;
    } // end switch


};

6. Allow the ESC Key to Close the Dialog

When the dialog is open, pressing the ESC key should close it

Besides our intercept of the TAB key, all other keys should work as they normally would. This means that pressing the ESC key should close anything that is open.

We can modify the Dialog.prototype.handleKeyDown function to allow for this.

Dialog.prototype.handleKeyDown = function(e) {

    var Dialog = this;
    var KEY_TAB = 9;
    var KEY_ESC = 27;

    switch(e.keyCode) {
        case KEY_TAB:
            /* Handle if TAB key is pressed (see above) */
        case KEY_ESC:
            Dialog.close();
            break;
        default:
            break;
    } // end switch

};

Demo & Source Code