Skip to content Home - me2 Accessibility logo

Modal dialog box

How to manage the focus for modal dialog boxes

You are here:

Modal Dialog

Modal dialog

A modal dialog is a dialog that restricts the user input to the dialog, so as long as the dialog is displayed, the user cannot interact (e.g. click links or buttons) with the surrounding page content. Modal dialogs are typically displayed with a semi-transparent, grey overlay covering the surrounding page content.

Keyboard focus

On a webpage, the keyboard focus is the ability to receive direct keyboard input. The keyboard focus is important because it determines which element on the page can be activated via keyboard.

Keyboard-only users typically move the keyboard focus through the page by pressing the Tab key (and Shift + Tab keys to move backwards). For example, a user can press the Tab key to move focus to a link, then press the Enter key to activate the link. This is equivalent to clicking the link with the mouse.

Focus management

To ensure a modal dialog is keyboard accessible, the keyboard focus should be managed as follows:

When the dialog opens

Move the focus to the dialog. The focus can be placed on either the overall dialog or an element inside the dialog (e.g. the dialog content element, heading, or a form control).

While the dialog is displayed

Constrain the keyboard focus inside the dialog. This means when the Tab key (or Shift + Tab keys) is pressed, the focus moves inside the dialog only and does not move to the surrounding page content.

When the dialog is closed

Move the focus back to the element that had focus before the dialog was opened.

Dialog implementation

Modal dialogs can be implemented either via ARIA markup or via the HTML <dialog> element.

ARIA dialog

In an ARIA dialog, the keyboard focus management is implemented via JavaScript.

In addition, the dialog must contain the attributes role="dialog" and aria-modal="true". This constrains the screen reader focus to the dialog content, ensuring the dialog is modal. In addition, the attributes ensure the dialog is announced as "Dialog" when initially displayed, informing users that the content is a dialog.

Adding an 'aria-label' or 'aria-labelledby' attribute to the dialog ensures it is announced with a label (e.g. "Dialog change username").

Ideally, add the attribute aria-haspopup="dialog" to the trigger button that opens the dialog. This ensures screen readers announce the button as "Opens dialog", so users know what functionality the button has before activating it.

Note that some screen readers on mobile devices do not consistently support this markup, for example, the screen reader may be able to access the page content outside the dialog. For this reason, in the example dialog provided below, we include screen reader-only text "Dialog start" and "Dialog end" in the dialog. This informs screen reader users where the dialog starts and ends.

Example dialog

This example uses the code provided below.

Example code

The following code is an example of focus management for modal dialogs.

JavaScript

{

//Array of dialog boxes that are currently displayed
let Dialogs = [];
//Base value for z-index
const ZindexBase = 999;
//Variable value for z-index
let Zindex = ZindexBase;
//CSS selector for elements that can gain keyboard focus
const Fselector = 'a[href],button,input:not([hidden]),select,textarea,audio,video,area,details,[contenteditable],[tabindex]:not([tabindex^="-"])';

//Global function
function openDialog(id,focusAfterClose){

    //Get reference to <div> with role="dialog"
    var dia = document.getElementById(id);

    //Get reference to content <div> of the dialog
    var con = dia.querySelector("div");

    //Ensure content div can be focused via JavaScript (but not by users pressing the Tab key)
    con.setAttribute("tabindex","-1");

    //Add focus sentinels to keep focus inside dialog box
    //The sentinels contain text "Dialog start" and "Dialog end" to clearly communicate that the content is a dialog box
    //and where the dialog box starts and ends. This can be a help when screen readers do not fully support dialog box modality
    cre("span",{"tabindex":"0","class":"gotoend"},dia,false,"Dialog start");
    cre("span",{"tabindex":"0","class":"gotostart"},dia,true,"Dialog end");

    //If focusAfterClose is not provided as an argument, use the currently focused element
    //This is where focus is placed after the dialog is (eventually) closed
    if(!focusAfterClose){
        var focusAfterClose = document.activeElement;
    }

    //Add to global array
    Dialogs.push([dia,focusAfterClose]);

    //Ensure dialog displays above other dialogs
    dia.style.zIndex = ++Zindex;

    //Show dialog
    dia.style.display = "block";
    dia.removeAttribute("aria-hidden");

    //If dialog contains an element with the 'autofocus' attribute, place focus on that
    //If no such element exists, place focus on the content div in the dialog
    focusOn([con.querySelector("[autofocus]"),con]);

    //If this is the first dialog that is displayed, add focus management to the page
    if(Dialogs.length === 1){
        document.addEventListener("focus",monitorFocus,true);
    }else{
        //If other dialogs are stacked behind the top dialog, ensure they are completely hidden from screen readers
        for(var i=0;i < Dialogs.length - 1;i++){
            Dialogs[i][0].setAttribute("aria-hidden","true");
        }
    }
}

//Global function
function closeDialog(id){

    //Get reference to <div> with role="dialog"
    var dia = document.getElementById(id);

    //Check if the dialog is the topmost (visible) dialog. This is usually the case
    if(dia === Dialogs.at(-1)[0]){

        //Remove from array
        var darr = Dialogs.pop();

        //If no other dialogs are displayed,
        if(Dialogs.length === 0){
            //Remove the focus event handler from the document so it can have a rest
            document.removeEventListener("focus",monitorFocus,true);
            //Reset z-index value
            Zindex = ZindexBase;
        }else{
            //Ensure the new topmost dialog is exposed to screen readers
            Dialogs.at(-1)[0].removeAttribute("aria-hidden");
        }

        //Place focus on the focusAfterClose element
        darr[1].focus();

        //Hide dialog
        darr[0].style.display = "none";

    }else{

        //Find dialog in array
        for(var i=0;i < Dialogs.length;i++){
            if(Dialogs[i][0] === dia){

                //Hide the dialog
                Dialogs[i][0].style.display = "none";

                //Switch focusOnClose for the dialog above this dialog
                Dialogs[i+1][1] = Dialogs[i][1];

                //Note that the focus is not changed - it remains on the top level dialog

                //Remove from array
                Dialogs.splice(i,1);
                break;
            }
        }
    }

    //Remove focus sentinels from dialog
    var sentinels = [...dia.querySelectorAll('span[class^="goto"]')];
    for(var sen of sentinels){
        sen.remove();
    }
}

const focusOn = function(arr,backwards){

    if(backwards){
        //If this is a nodeList (e.g. from .documentQuerySelectorAll), make it into an array to make it reversible
        if(arr instanceof NodeList){
            arr = Array.from(arr);
        }
        //Reverse array
        arr = arr.reverse();
    }

    //Try to place focus on the elements, one by one
    for(const el of arr){

        //If el does not exist (e.g. when no 'autofocus' attribute found in dialog), does not support focus method, or is disabled
        if(!el || !el.focus || el.disabled){
            continue;
        }

        //Attempt to place focus on element
        el.focus();

        //If focus is successfully placed,
        if(document.activeElement === el){
            return true;
        }
    }
    //Note, we are not using the return value at this stage
    return false;
}

const monitorFocus = function (ev){

    //Get reference to top level (visible) dialog
    var darr = Dialogs.at(-1);

    //If focus happened on the top level dialog or an element inside the dialog
    if(darr[0].contains(ev.target)){

        //If focus sentinel at top got focus, move focus to the end
        if(ev.target.className === "gotostart"){
            //Get all (potentially) focusable elements inside the dialog, then focus the first one possible
            focusOn(darr[0].querySelectorAll(Fselector));

        //If focus sentinel at end got focus, move focus to the top
        }else if(ev.target.className === "gotoend"){
            //Get all (potentially) focusable elements inside the dialog, then focus the last one possible
            focusOn(darr[0].querySelectorAll(Fselector),true);
        }

    //Focus happened outside top level dialog
    }else{
        //Place focus back on content div in top level dialog
        //Not looking for 'autofocus' attribute as this is not the initial display of the dialog
        darr[0].querySelector("div").focus();
    }
}

//Auxiliary function to create elements and add to DOM
const cre = function(name,attributes,parent,last,content){
    var el = document.createElement(name);
    if(attributes){
        for(const n of Object.keys(attributes)){
            el.setAttribute(n,attributes[n]);
        }
    }
    if(parent){
        if(last){
            parent.appendChild(el);
        }else{
            parent.insertBefore(el, parent.firstChild);
        }
    }
    if(content){
        el.textContent = content;
    }
    //return el;
}

}

HTML

<button aria-haspopup="dialog" onclick="openDialog('dialog1',this)">
    Change username
</button>
. . .

<div id="dialog1" role="dialog" aria-modal="true" aria-label="Change username" style="display:none">
    <div>
        <h2>
            Change username
        </h2>
        <label for="un">
            New username
        </label>
        <input autofocus type="text" id="un" autocomplete="username">
        <button onclick="openDialog('dialog2',this)">
            Cancel
        </button>
        <button onclick="if(checkForErr(this)){openDialog('dialog3',this);closeDialog('dialog1')}">
            Submit
        </button>
    </div>
</div>

CSS

[role="dialog"]{
    position: fixed;
    left: 0;
    top: 0;
    width: 100%;
    height: 100%;
    background-color: rgba(99,99,99,0.7);
}

div[role="dialog"] > div{
    position: absolute;
    left: 50%;
    top: 50%;
    transform: translate(-50%,-50%);
    max-width: 90%;
    max-height: 90%;
    margin: auto;
    padding: 3%;
    background-color: white;
    overflow: auto;
    /*Rounded corners without scroll bar protruding*/
    clip-path: inset(0 round 15px);
}

@media only screen and (max-width:500px){
    div[role="dialog"] > div{
        min-width: 90%;
    }
}

body:has([role="dialog"][style*="block"]){
    overflow: hidden;
}

[role="dialog"] > div:focus, [role="dialog"] > div:focus-visible{
    outline:0;
}

[role="dialog"] span[class^="goto"]{
    opacity:0;
}

<dialog> element

Using the HTML <dialog> element is much simpler as the keyboard focus is managed by the browser. In addition, it has better support by mobile screen readers than the ARIA dialog.

The example below uses the <dialog> element to implement the same three dialogs used in the ARIA example.

Example

HTML

<button aria-haspopup="dialog" onclick="dialog1_HTML.showModal();">
    Change username
</button>
. . .

<dialog id="dialog1_HTML" aria-label="Change username">
    <div>
        <h2>
            Change username
        </h2>
        <label for="un">
            New username
        </label>
        <input autofocus type="text" id="un" autocomplete="username">
        <button onclick="dialog2_HTML.showModal()">
            Cancel
        </button>
        <button onclick="if(checkForErr(this)){dialog1_HTML.close();dialog3_HTML.showModal()}">
            Submit
        </button>
    </div>
</dialog>

CSS

#dialog1_HTML::backdrop{
    background-color: rgba(99,99,99,0.6);
}

Terms of Use

This software is being provided "as is" - without any express or implied warranty. In particular, me2 Accessibility does not make any representation or warranty of any kind concerning the reliability, quality, or merchantability of this software or its fitness for any particular purpose. In addition, me2 Accessibility does not guarantee that use of this tool will ensure the accessibility of your web content or that your web content will comply with any specific accessibility standard.

Creative commons licence - logo
This work is licensed under a Creative Commons License

Contact Us

Need a quote / Have a query?

Please get in touch

Contact Us
Level AA conformance, W3C WAI Web Content Accessibility Guidelines 2.2 Certified WCAG level AA. me2Accessibility logo

Change username

Confirm cancel

Are you sure you want to cancel?

Username changed

Your username has been changed