In the last article I covered why you should not use the `<dialog>` element, now or within the foreseeable future. But dialogs are everywhere in web applications, so there must be some other pattern that covers all use cases—right? Well, kind of.
Quick Note: The journey to find a “perfect pattern” for dialogs is one of unending frustration. As recently as 2019 one particular dialog pattern worked neatly across the board, but I discovered by testing in preparation for this article that it is no longer fully supported by VoiceOver. Learn from my mistakes: test the crap out of every “accessible” markup pattern you find online; test it until you run out of things to test it with; test it multiple times a year. Browser and OS updates can impact accessibility in unforeseen ways….Anyway, back to the content.
Managing Focus
Focus management is the most common stumbling block for the dialog component. Developers either fail to move the focus to the dialog or fail to trap the screen reader and keyboard focus inside of it. In neglecting to move focus to the dialog upon opening, they have left screen reader users wondering whether the dialog has opened as they expected–and stranded them to comb through the DOM and find it for themselves. Keyboard users may be able to see the open dialog, but they still have to Tab their focus to it; and who knows whether the dialog is up or down in the DOM, at the top or at the bottom.
Where Do I Set Focus?
The answer used to be obvious: set focus on the container. This solution used to work great across all browsers: the screen reader announced the new context (“dialog” or “modal dialog”) along with the dialog’s accessible name, and it then paused or continued reading the contents of the dialog. It was perfect because it started at the beginning of the content and introduced the user to the container.
The old way:
<div role="dialog" aria-label="Dialog Name" tabindex="-1">...</div>
<script>
document.querySelector('div[role="dialog"]').focus();
</script>
But updates and changing technologies always introduce problems. In 2020, this pattern is no longer compatible with VoiceOver on Firefox and Chrome (macOS): in these pairings focus does move to the dialog container, but VoiceOver is unable to open the lid and enter the dialog contents themselves. Although it is true that VoiceOver and Chrome is an uncommon combination (3% of users according to the latest WebAIM Screen Reader User Survey), except among sighted developers, and that VoiceOver and Firefox is known to be an unruly mess, but since I can’t physically prevent people from using these stacks, there’s the rub. As a minor note, this approach also loses some NVDA functionality: the screen reader no longer announces that the user has entered a dialog. For all the user knows, they’re now on a totally different screen.
At the time of writing, there is a “best but not perfect” option: use and target role="document"
.
The new way:
<div role="dialog" aria-label="Dialog Name">
<div role="document" tabindex="-1">...</div>
</div>
<script>
document.querySelector('div[role="document"]').focus();
</script>
In nearly every combination of screen reader and browser, this pattern moves focus to the dialog effectively, causes screen readers to announce “dialog,” “modal dialog,” or “web dialog” when they enter, and meets all criteria significantly better than simply focusing the dialog container.
This example illustrates the importance of constant testing with assistive technologies.. Incremental browser and screen reader updates can have unforeseen effects on patterns like this one that aren’t fixed until years later.
How Do I Trap Focus?
This is yet another troublesome requirement for dialogs: if the dialog does not “trap” screen reader and keyboard focus within its boundaries, then it is extremely easy for users to wander out of the dialog, back to the page behind it, and get lost. Keyboard users should find that pressing Tab cycles focus through elements in the dialog, moving from the bottom back to the top, and screen reader users should experience a dialog as if it were a separate document.
There’s a polyfill for that. While trapping screen reader users inside a dialog is as easy as applying aria-hidden="true"
to the rest of the webpage, trapping keyboard or Tab focus is a different story. It used to require management of tabindex
values inside and outside of the dialog. Now — enter, “inert.”
The WICG inert polyfill hides an element and its children from user interaction events and assistive technology. It takes care of trapping screen reader and keyboard focus at once, and it’s surprisingly well-adapted to modern screen reader and browser combinations. The WICG documentation has full instructions on how to install the polyfill, and it can be included with a simple script (below). Just make sure your dialog is a sibling of the <main>
element and apply inert = true
when the dialog opens.
<main> ... </main>
<div role="dialog" aria-label="Dialog Name">
<div role="document" tabindex="-1"> ... </div>
</div>
<script>
document.querySelector('main').inert = true;
</script>
<script src="node_modules/wicg-inert/dist/inert.min.js"></script>
Dialog Heading
One beginner-level accessibility requirement is to include one, and only one, level 1 heading on each webpage: <h1>
. This is important, because it introduces the webpage as a separate, independent document with a single topic or purpose. Every heading following the h1 is then supposed to be a higher level: h2, h3, etc.
Dialogs are an exception to this rule. Developers are tempted to wrap the dialog title in an <h2>
or <h3>
, depending on how it relates to content on the page behind it, but that is missing the point. A dialog is a modal experience: it interrupts the user flow and separates the user from the context of the page. As a separate, independent document, therefore, it should begin with an <h1>
.
<div role="dialog" aria-labelledby="dialog-title">
<div role="document" tabindex="-1">
<h1 id="dialog-title">Dialog Name</h1>
...
</div>
</div>
Opening and Closing the Dialog
For users who can’t see their screen, it may not be clear that a dialog has opened. And a dialog is a major change of context, complicated by the fact that the pattern is unstable and often implemented incorrectly. That’s why it is important not only to announce “dialog” when it opens, but to prepare the user’s expectations before they open it.
Maybe ARIA?
There is an ARIA solution for this: apply aria-haspopup="dialog"
to the button that triggers the dialog. But this still isn’t compatible with most Windows screen readers, which announce incorrectly that the button will open a “menu” or “submenu” — and that would make for a mystifying experience when they do not. VoiceOver mostly supports the attribute value, but not at all with Chrome.
The best approach is to avoid aria-haspopup and instead append a message to the accessible name of the button that triggers the dialog. For instance, for a dialog used to enter a security PIN, aria-label="Enter PIN, opens dialog"
.
Closing a Dialog
There are common usability patterns for closing dialogs that should be maintained. Dialogs should always have a formal Close, Cancel or X button; they should respond to a user clicking outside the dialog; and they should close on the user pressing the Esc key.
The “X” close button is commonly found in the top-right of the dialog, and it is commonly not labeled for screen readers. (The last thing we want to do is remind screen reader users of their ex, which is exactly how that button would be announced.) Be sure to label this button with aria-label="Close dialog"
. The extra wording “dialog” is helpful, since in some screen reader browser combinations, or for dialogs with inferior implementation patterns, it is possible that the screen reader didn’t announce “dialog” when it opened.
The Esc key press to close is as simple as listening for the right “keydown” event. There is the possibility, though, that this scripting can be more complex. Expanded dropdowns, or <select>
elements, collapse in response to the Esc key, so event listeners must be targeted carefully to avoid closing the entire dialog when the user was only trying to collapse a dropdown within the dialog.
Closing a dialog when a user clicks outside its bounds requires only one line of code. Just make sure you target whatever “backdrop” element you are using to partially hide the page behind.
document.addEventListener('click', function(e) { if (e.target.id == 'backdrop') closeDialog(); }, false);
Summary
Writing this article required me to test multiple dialog patterns across a variety of screen readers, browsers, and operating systems, and to say the least, it was a journey. There is no “perfect” markup pattern for dialogs from a usability perspective. The degree of specificity required to outline the “best” pattern, as well as the need for a polyfill to make it functional, is a testament to the obvious need for a dialog element with support from browsers. For now, do the best you can with the pattern above, and watch out for browser updates that may impact the user experience.