Build an Accessible Modal Dialog
Are you in the right place? If you are constrained to a third-party component library, see the Modal Accessibility Checklist and ensure that it passes. Otherwise, follow the guidance here to choose the best option for your project.
Use the NYSDS Modal Web Component
The NYSDS Modal is a web component you can drop into your project and use right away. For most teams, this is the simplest way to achieve an accessible component. This means teams don’t need to manually implement accessibility or test for it.
What's already handled for you:-
Keyboard Navigation
Tab, Enter, Space, and Arrows work as intended
-
Screen reader support
Tested with NVDA, JAWS, and VoiceOver
-
Focus management
Focus stays where users expect it
-
Voice control
Supports Windows and macOS
-
Zoom magnification
Works correctly at 200% browser zoom
-
WCAG 2.2 AA conformant
Meets New York State standards
Alternatives
For projects not ready to adopt the Design System Whether you seek to remediate an existing component or build one from scratch, these reference implementations will ensure an accessible outcome. For more details, read about this pattern.
Configure the native HTML dialog element
When to use
If your team has source-level control over UI code,
wants to avoid error-prone recreation of semantics, z-index, backdrop, and focus management,
and has no hard requirements in excess of what native HTML provides,
then the dialog element invoked as a modal is the number one recommendation.
<!-- This trigger button is configured (in the JavaScript file)
to activate a dialog that has the ID passed to `data-nysa11y-dialog` -->
<button type="button"
class="nysa11y button"
data-component="button"
data-html="native"
data-nysa11y-dialog="#myModal">Show me places to go</button>
<!-- This is the native HTML5 `dialog`.
It opens with modal behavior due to being invoked
with the native method `.showModal()` -->
<dialog aria-labelledby="VNYS"
class="nysa11y dialog"
data-component="dialog"
data-html="native"
id="myModal">
<header>
<div class="heading">
<!-- This heading is the accessible name,
first focused element, and visual and aural title
of the dialog. Tested across operating systems,
browsers, devices, and screen readers for
maximum dependability. -->
<h1 autofocus tabindex="-1" id="VNYS">Visit New York State</h1>
</div>
<!-- In JavaScript, activation of `data-part="exit"`
is wired to the native `.close()` method -->
<button data-part="exit"
data-component="button"
data-html="native"
class="nysa11y button ghost"
type="button">Close</button>
<mark class="subhead">Expand your horizons by exploring ours!</mark>
</header>
<main aria-label="dialog content">
<p>
This is the main content region.
When the dialog opens do you perceive
<strong>focus</strong> strongly evident on the heading?
</p>
<div>
<nys-select label="Keyboard interaction test" width="md">
<span slot="description">
Activate the <code class="element">nys-select</code> component to open it,
but before making a selection dismiss it with the <kbd>Escape</kbd> key.
Ensure this action affects only the <code class="element">nys-select</code>,
and the dialog remains open.
Once the <code class="element">nys-select</code> closes,
activate <kbd>Escape</kbd> again.
The dialog should then close.
</span>
<option>One</option>
<option>Two</option>
<option>Three</option>
<option>4,567,890</option>
</nys-select>
</div>
<hr class="nysa11y divider" data-component="divider" data-html="native">
<h2 aria-label="I love New York">I❤️New York</h2>
<p>
New York State is <a href="https://www.iloveny.com/things-to-do/history/" target="_blank">historic</a>.
It's untamed. It's iconic. But more importantly, it's anything you want it to be.
From the lights of <a href="https://www.iloveny.com/places-to-go/new-york-city/city-guide-new-york-city/" target="_blank">Times Square</a>
to the mists of <a href="https://www.iloveny.com/places-to-go/niagara/spotlight-buffalo-niagara-falls/" target="_blank">Niagara Falls</a>,
<a href="https://www.iloveny.com/places-to-go/adirondacks/city-guide-lake-placid/" target="_blank">Lake Placid's</a> Olympic legacy to
<a href="https://www.iloveny.com/places-to-go/central/spotlight-cooperstown/" target="_blank">Cooperstown's</a> baseball roots,
<a href="https://www.iloveny.com/places-to-go/capital-saratoga/albany-city-guide/" target="_blank">Albany's</a> 400-year history to
<a href="https://www.iloveny.com/places-to-go/chautauqua-allegheny/jamestown-spotlight/" target="_blank">Jamestown's</a> comedy treasures, the castles of the
<a href="https://www.iloveny.com/places-to-go/1000-islands/" target="_blank">Thousand Islands</a> to
<a href="https://www.iloveny.com/places-to-go/long-island/spotlight-long-island/" target="_blank">Long Island's</a>
<a href="https://www.iloveny.com/listing/gold-coast-mansions-of-long-island/14992/" target="_blank">Gold Coast mansions</a>, the
<a href="https://www.iloveny.com/places-to-go/hudson-valley/" target="_blank">Hudson Valley</a>'s inspiring landscapes to the
<a href="https://www.iloveny.com/places-to-go/catskills/" target="_blank">Catskills'</a> sweeping mountain views and stunning
<a href="https://www.iloveny.com/places-to-go/finger-lakes/" target="_blank">Finger Lakes</a> wine country—there's something for everyone.
</p>
</main>
<footer aria-label="dialog actions">
<button data-component="button"
data-html="native"
class="nysa11y button ghost"
type="button"
onClick="alert('Perhaps the Adirondacks will interest you?');">
<span class="button__accname">Not now</span>
</button>
<!-- In JavaScript, activation of `exitDialog()`
is wired to the native `.close()` method -->
<button data-component="button"
data-html="native"
class="nysa11y button"
type="button"
onClick="exitDialog();">
<span class="button__accname">Plan my trip</span>
</button>
</footer>
</dialog>
/**
* NYSA11y Dialog (native modal)
* Path: /assets/nysa11y/dialog-native.css
* Depends on /assets/nysa11y/dialog-native.js
*/
@layer nysa11y {
html:has(dialog.nysa11y[open]) {
/* prevent `body` scrolling */
overflow: hidden;
scrollbar-gutter: stable;
}
body:has(.nysa11y) {
font-size: 16px; /* Set the 1.0rem baseline */
dialog.nysa11y.dialog[data-component="dialog"][data-html="native"] {
--nysa11y-dialog-min-usable-height: 4.5rem;
*,
*:before,
*:after {
box-sizing: border-box;
}
font-family: var(
--nys-font-family-sans,
"Proxima Nova",
-apple-system,
"BlinkMacSystemFont",
"Segoe UI",
"Roboto",
"Helvetica",
"Arial",
sans-serif,
"Apple Color Emoji",
"Segoe UI Emoji",
"Segoe UI Symbol"
);
border-radius: var(--nys-radius-lg, 0.5rem);
border:
var(--nys-color-neutral-200, #bec0c1),
1px solid;
color: var(--nys-color-text, #1b1b1b);
font-size: 1rem;
min-height: var(--nysa11y-dialog-min-usable-height);
overflow: hidden; /* prevent `body` scrolling */
overscroll-behavior: contain; /* prevent `body` scrolling */
padding: 1.5rem;
width: var(--nysa11y-dialog-width);
&[open] {
display: flex;
flex-direction: column;
gap: var(--nys-space-300, 1.5rem);
}
&::backdrop {
background-color: var(--nys-color-black-transparent-700, rgba(27, 27, 27, 0.7));
overflow: hidden; /* prevent `body` scrolling */
overscroll-behavior: contain; /* prevent `body` scrolling */
}
&:has(main:not(:empty)) {
min-height: 18rem;
}
& header {
align-items: start;
display: grid;
gap: var(--nys-space-100, 0.5rem);
grid-template-areas:
"title close"
"subtitle subtitle";
grid-template-columns: 1fr auto;
grid-template-rows: auto auto;
justify-content: space-between;
line-height: 1;
& .heading {
grid-area: title;
& h1 {
border-radius: var(--nys-radius-lg, 0.5rem);
display: inline-flex;
font-size: 1.5rem !important; /* defensive */
font-weight: 700 !important; /* defensive */
line-height: inherit !important; /* defensive */
margin-block: 0.5rem 0 !important; /* defensive */
&:focus {
outline: solid var(--nys-border-width-md, 2px) var(--nys-color-focus, #004dd1);
outline-offset: var(--nys-border-width-md, 6px);
}
}
}
/* Modification of the `ghost` button style
to better fit this usage */
& [data-part="exit"] {
border: 1px solid transparent;
display: flex;
font-size: 0.75rem;
gap: 0.25rem;
grid-area: close;
min-height: 2.5rem;
padding-block: 0.65rem;
padding-inline: 0.75rem;
&::after {
content: "\2715" / "";
font-size: 1.5rem;
line-height: 0.75;
}
&:hover {
background-color: var(--nys-color-black-transparent-100, #0000001a);
}
&:active {
background-color: var(--nys-color-black-transparent-200, #00000033);
}
}
/* `mark` communicates that text is "highlighted,"
to set it off from body text */
& mark {
background-color: transparent;
font-size: var(--nys-font-size-lg, 1.125rem);
font-weight: var(--nys-font-weight-semibold, 600);
grid-area: subtitle;
line-height: var(--nys-font-lineheight-body-lg, 1.75rem);
}
}
& main {
flex-grow: 1;
overflow: auto;
&:not(:empty) {
min-height: var(--nysa11y-dialog-min-usable-height);
}
/* better accommodate focus ring of side-aligned focusable elements */
padding: 1px;
}
& footer {
display: flex;
flex-direction: row;
gap: var(--nys-space-250, 1.25rem);
justify-content: end;
}
}
}
@media (max-width: 479px) {
body:has(.nysa11y) {
dialog.nysa11y.dialog[data-component="dialog"][data-html="native"] {
--nysa11y-dialog-width: 90vw;
}
}
}
@media (min-width: 480px) {
body:has(.nysa11y) {
dialog.nysa11y.dialog[data-component="dialog"][data-html="native"] {
--nysa11y-dialog-width: 85vw;
}
}
}
@media (min-width: 768px) {
body:has(.nysa11y) {
dialog.nysa11y.dialog[data-component="dialog"][data-html="native"] {
--nysa11y-dialog-width: 80vw;
}
}
}
@media (min-width: 1024px) {
body:has(.nysa11y) {
dialog.nysa11y.dialog[data-component="dialog"][data-html="native"] {
--nysa11y-dialog-width: 75vw;
}
}
}
@media (min-width: 1280px) {
body:has(.nysa11y) {
dialog.nysa11y.dialog[data-component="dialog"][data-html="native"] {
--nysa11y-dialog-width: 70vw;
}
}
}
}
/**
* NYSA11y Dialog (native invocation)
* Path: /assets/nysa11y/dialog-native.js
* Depends on /assets/nysa11y/dialog-native.css
*/
const nysa11y = window.nysa11y || {};
class Dialog {
#selectorRoot = '[data-component="dialog"].nysa11y';
#selectorTrigger = "[data-nysa11y-dialog]";
#selectorExit = '[data-part="exit"]';
constructor(options = {}) {
this.container = options.container || document;
this.init();
}
init() {
const triggers = this.container.querySelectorAll(this.#selectorTrigger);
const dialogs = this.container.querySelectorAll(this.#selectorRoot);
if (!triggers.length && !dialogs.length) return;
triggers.forEach((trigger) => {
trigger.removeEventListener("click", this.#handleTriggerClick);
trigger.addEventListener("click", this.#handleTriggerClick);
});
dialogs.forEach((dialogEl) => {
const exits = dialogEl.querySelectorAll(this.#selectorExit);
exits.forEach((exitBtn) => {
exitBtn.removeEventListener("click", this.#handleExitClick);
exitBtn.addEventListener("click", this.#handleExitClick);
});
});
}
enter(dialogEl) {
if (!dialogEl || typeof dialogEl.showModal !== "function") return;
if (dialogEl.open) return;
/* invoke the native method to open `dialog` with modality */
dialogEl.showModal();
}
exit(dialogEl) {
if (!dialogEl || typeof dialogEl.close !== "function") return;
if (!dialogEl.open) return;
/* invoke the native method to close `dialog` */
dialogEl.close();
}
#handleTriggerClick = (event) => {
const trigger = event.currentTarget;
const selector = trigger.getAttribute("data-nysa11y-dialog");
if (!selector) return;
const dialogEl = this.container.querySelector(selector);
if (!dialogEl) {
console.error(`Error: The "${selector}" dialog is not found.`);
return;
}
this.enter(dialogEl);
};
#handleExitClick = (event) => {
const exitBtn = event.currentTarget;
const dialogEl = exitBtn.closest(this.#selectorRoot);
if (!dialogEl) return;
this.exit(dialogEl);
};
}
nysa11y.Dialog = Dialog;
window.exitDialog = () => {
const openDialog = document.querySelector("dialog[open]");
if (openDialog && nysa11y.dialogInstance) {
nysa11y.dialogInstance.exit(openDialog);
}
};
document.addEventListener("DOMContentLoaded", () => {
nysa11y.dialogInstance = new nysa11y.Dialog();
});
About this pattern
The dialog experience is a dramatic re-framing of users' context and focus, second only to a full page navigation. In fact, native operating system dialogs and early World Wide Web browser dialogs were actual new system or application windows displayed above the window of origination.
Subsequent to the late 1990s, most web browser dialogs were faked to visually appear like independent windows without actually being so. The ensuing years of inconsistency and poor accessibility led many user interface experts to recommend caution in the use of so-called dialogs. Even today, designers may reasonably consider different forms of disclosure and navigation as alternative paradigms.
Decades of creative experimentation led to the addition of a
dialog
element to the official HTML5 specification.
This native element, which reached baseline browser support
in 2022, allows authors leeway to craft a range of user experiences.
The HTML5 dialog is the basis of the implementation shown here, and is the number one recommendation for any custom dialog development today.
Though it is easier than ever to produce an accessible dialog, knowledge and care are still required to ensure this outcome.
Let's deepen our understanding.
High level presentation
A dialog is a real or apparent window that presents separately from the primary user interface surface.
- Visually, dialogs appear in the foreground "above" the main screen
- Aurally, dialogs are perceived as discrete, self-contained areas
- A dialog may be automatically opened and closed by an operating system or application, or manually activated by the user; manual control is preferred
- In the majority of cases, a user will interact with dialog content in order to effect an outcome that closes the dialog and moves the user to the primary surface
- There is no set size for dialogs
- It is possible, though not recommended, for dialogs to completely fill a visual viewport by default; designers should account for cases in which this is actually the best user experience, for example in screen magnification and mobile portrait orientation scenarios
- More commonly, dialogs are presented visually as relatively small boxes centrally located on screen
What is a "modal"?
A "modal" is an intensification of the dialog pattern.
- Typically the primary content "below" a modal dialog is visually dimmed to varying extents; with modals, non-dialog content should be literally inert, unreachable, and non-interactive
- Interaction with other application or operating system interfaces is substantially blocked, with focus held within the dialog, until required actions are complete
-
The HTMLDialogElement Web API
allows authors to determine modality merely by the choice of native instance method to open a dialog:
.show()opens a non-modal dialog.showModal()opens a modal dialog
Responsiveness
Like any content, a dialog should gracefully adapt to orientation change, mobile viewports, text size increase, and screen magnification. Design, test, and build for scenarios in which a dialog may fill an entire screen, may require scrollbars, and may accommodate a 200% font size increase.
Focus movement
Whether it is visible or not, some page element always has programmatic focus, even if it happens to be the body, which typically has focus on page load.
Mouse users take note: generally any element clicked by a mouse gains focus at that moment.
A proper modal dialog must pull focus into itself upon opening.
It is essential that this happen, and likewise that while the dialog remains open focus be almost entirely trapped within.
As described in the corresponding Modal Accessibility Checklist, in the majority of cases tabindex will remain inside the dialog, wrapping between the top and bottom of the dialog's user interface.
Let's consider a scenario in which a user activates a button in main content to open a modal dialog. The button interaction (whether by mouse, keyboard, voice command, or double-tap) sets focus on the button for a short period of time (miliseconds), until the dialog user interface is ready to gain focus. This button is the last element in main content to have focus and is the interaction trigger that spawns the dialog. Consequently when the dialog is closed, focus must return whence it came back to this trigger button, provided the main user interface has not changed so dramatically that the button is no longer present. Failure to return focus from a dialog to its trigger is a severe and unfortunately common issue.
Usefully, authors may choose exactly where focus lands in a new dialog. While the various considerations are beyond the scope of this guide (see also the Keyboard Interaction → Note aside), one of the most effective techniques is to use the placement of focus to ensure the dialog's accessible name is immediately discovered.
An accessible name
A dialog must identify itself clearly to all users upon opening.
The best accessibility is one that works for everyone, preferably relying on the in-built capabilities of the web platform.
A single label, title, or heading can serve all users equitably with minimal effort.
In our example, a single H1 element is the visual, aural, and semantic label for the dialog by virtue of element choice, DOM placement, and attribute settings.
Across devices it consistently demands first focus, without the need for explicit JavaScript .focus().
In a browser's accessibility tree, the text content of this H1 is literally the
accessible name of the dialog.
Traverse and close
Once inside a modal dialog, all content structure and interaction rules apply the same as on the primary surface; they are just contained within a (usually) smaller space. The key difference is in the conventions to uphold for dialog dismissal:
-
Activation of the Escape key must close the dialog, except while the user interacts with a child element that has its own response to Escape,
for example, a native HTML
select. However, not every user has access to an Escape key. -
The BBC Accessibility program founders
advise:
All modal dialogs must have a visible, accessible control within the dialog content, allowing the user to close the dialog; for example, a button labeled Close or a submit button for a dialog with a form. Do not assume that the user can press an Escape key or click outside the content area, for example.
Best quality references
This document describes and presents nearly the simplest possible modal dialog. More is possible and may be reasonably demanded by your particular set of users. When building upon our solid foundation, these resources are the best available:
- Modal Dialog from A11y Quest (ex-BBC)
- Dialog Modal HTML and Dialog Modal Design from Atomic Accessibility
- Where to Put Focus When Opening a Modal Dialog from Adrian Roselli
-
MDN HTML
dialogelement from the Mozilla Developer Network - MDN Web APIs → HTMLDialogElement from the Mozilla Developer Network
- Dialog (Modal) Pattern from the W3C ARIA Authoring Practices Guide
- The dialog element from HTML: The Living Standard