Accessibility pattern · Overlays
Modal dialog
showModal() does the hard parts: the rest of the page becomes inert, focus moves into the dialog, and Escape closes it. What is left is naming it, describing it, and sending focus back to the button that opened it.
- WCAG criteria
- 7
- Keyboard rules
- 4
- Checked with
- axe, keyboard and the inspector
Live demo
Try it
Use it with a mouse, a keyboard or a screen reader. The inspector beside it shows what the browser tells assistive technology as you go: focus, state changes and announcements.
The demo works without JavaScript only as far as its HTML does; the inspector needs JavaScript.
Keyboard
Keys it answers to
Every action works without a pointer.
| Key | What it does |
|---|---|
| Enter or Space | On the trigger button, opens the dialog and moves focus to its first field. |
| Tab or ShiftTab | Moves through the dialog's controls only; the page behind cannot be reached. |
| Escape | Closes the dialog without saving and returns focus to the trigger. |
| Enter | In a field, submits the form, which saves and closes the dialog. |
Screen readers
What it announces
Written from the roles, names and states in the markup.
| When | Expected announcement |
|---|---|
| The dialog opens | Edit profile, dialog. Your name and bio appear on your public page. Display name, edit text, Asha Rao |
| Focus reaches the close button | Close, button |
| The dialog closes | Edit profile, button (focus is back on the trigger) |
| Changes are saved | Profile saved |
These are expected announcements, not recordings. Wording and order differ between screen readers and browsers.
Code
Copy the code
The exact files this demo runs on. The styles are served with a prefix that keeps this site's own styles out of the demo; what you copy is the original.
<div class="ap-modal-demo" data-ap-modal>
<button type="button" class="ap-btn ap-btn--primary" data-ap-open aria-haspopup="dialog">
<svg class="ap-btn__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>
Edit profile
</button>
<p class="ap-modal-demo__status" role="status"></p>
<dialog class="ap-modal" aria-labelledby="modal-name" aria-describedby="modal-desc">
<form class="ap-modal__form" method="dialog">
<div class="ap-modal__top">
<h2 class="ap-modal__name" id="modal-name">Edit profile</h2>
<button type="button" class="ap-modal__close" data-ap-close aria-label="Close">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M6 6l12 12M18 6 6 18"/></svg>
</button>
</div>
<p class="ap-modal__desc" id="modal-desc">Your name and bio appear on your public page.</p>
<div class="ap-field">
<label class="ap-label" for="modal-display-name">Display name</label>
<input class="ap-input" id="modal-display-name" name="name" autocomplete="nickname" value="Asha Rao" autofocus />
</div>
<div class="ap-field">
<label class="ap-label" for="modal-bio">Bio</label>
<textarea class="ap-input" id="modal-bio" name="bio" rows="3">Product designer in Pune.</textarea>
</div>
<div class="ap-modal__actions">
<button type="button" class="ap-btn" data-ap-close>Cancel</button>
<button type="submit" class="ap-btn ap-btn--primary" value="save">Save changes</button>
</div>
</form>
</dialog>
</div>
/* Shared primitives: buttons and form fields used across the patterns.
Uses the --ap-* design tokens. */
.ap-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 40px;
padding: 0 16px;
border: 1px solid var(--ap-border-strong);
border-radius: var(--ap-radius-sm);
background: var(--ap-surface);
color: var(--ap-text);
font: inherit;
font-weight: 600;
line-height: 1.2;
text-decoration: none;
white-space: nowrap;
cursor: pointer;
transition: background-color var(--ap-duration) var(--ap-ease), border-color var(--ap-duration) var(--ap-ease), box-shadow var(--ap-duration) var(--ap-ease);
}
.ap-btn:hover {
background: var(--ap-surface-2);
}
.ap-btn:focus-visible {
outline: 2px solid var(--ap-focus);
outline-offset: 2px;
}
.ap-btn:disabled,
.ap-btn[aria-disabled="true"] {
opacity: .55;
cursor: not-allowed;
}
.ap-btn--primary {
border-color: var(--ap-accent);
background: var(--ap-accent);
color: var(--ap-on-accent);
box-shadow: var(--ap-shadow-sm);
}
.ap-btn--primary:hover {
border-color: var(--ap-accent-hover);
background: var(--ap-accent-hover);
}
.ap-btn--danger {
border-color: var(--ap-danger);
background: var(--ap-danger);
color: var(--ap-surface);
}
.ap-btn--danger:hover {
filter: brightness(1.08);
}
.ap-btn--ghost {
border-color: transparent;
background: transparent;
}
.ap-btn--ghost:hover {
background: var(--ap-surface-2);
}
.ap-btn__icon {
width: 18px;
height: 18px;
flex-shrink: 0;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.ap-field {
display: grid;
gap: 6px;
}
.ap-label {
color: var(--ap-text);
font-weight: 600;
font-size: .9375rem;
}
.ap-hint {
margin: 0;
color: var(--ap-text-3);
font-size: .875rem;
line-height: 1.45;
}
.ap-error {
display: flex;
align-items: flex-start;
gap: 6px;
margin: 0;
color: var(--ap-danger);
font-size: .875rem;
font-weight: 600;
line-height: 1.45;
}
.ap-input {
width: 100%;
min-height: 44px;
padding: 10px 12px;
border: 1px solid var(--ap-border-strong);
border-radius: var(--ap-radius-sm);
background: var(--ap-surface);
color: var(--ap-text);
font: inherit;
line-height: 1.4;
transition: border-color var(--ap-duration) var(--ap-ease), box-shadow var(--ap-duration) var(--ap-ease);
}
.ap-input::placeholder {
color: var(--ap-text-3);
}
.ap-input:hover {
border-color: var(--ap-text-2);
}
.ap-input:focus-visible {
outline: 2px solid var(--ap-focus);
outline-offset: 1px;
border-color: var(--ap-focus);
}
.ap-input[aria-invalid="true"] {
border-color: var(--ap-danger);
box-shadow: inset 4px 0 0 var(--ap-danger);
}
textarea.ap-input {
resize: vertical;
min-height: 88px;
}
@media (prefers-reduced-motion: reduce) {
.ap-btn,
.ap-input { transition: none; }
}
/* Modal dialog. Uses the --ap-* design tokens and the shared primitives. */
.ap-modal-demo {
display: grid;
justify-items: center;
gap: 14px;
}
.ap-modal-demo__status {
min-height: 1.5em;
margin: 0;
color: var(--ap-success);
font-weight: 600;
}
.ap-modal {
width: min(92vw, 460px);
max-height: min(86vh, 640px);
padding: 0;
border: 1px solid var(--ap-border);
border-radius: var(--ap-radius-lg);
background: var(--ap-surface);
color: var(--ap-text);
box-shadow: var(--ap-shadow-lg);
overflow: auto;
opacity: 1;
transform: none;
transition: opacity 200ms var(--ap-ease), transform 200ms var(--ap-ease), overlay 200ms allow-discrete, display 200ms allow-discrete;
}
.ap-modal:not([open]) {
opacity: 0;
transform: translateY(8px) scale(.98);
}
@starting-style {
.ap-modal[open] {
opacity: 0;
transform: translateY(8px) scale(.98);
}
}
.ap-modal::backdrop {
background: var(--ap-scrim, rgb(9 9 11 / .5));
backdrop-filter: blur(2px);
transition: opacity 200ms, overlay 200ms allow-discrete, display 200ms allow-discrete;
}
@starting-style {
.ap-modal[open]::backdrop { opacity: 0; }
}
.ap-modal__form {
display: grid;
gap: 16px;
padding: 22px 24px 24px;
}
.ap-modal__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.ap-modal__name {
margin: 0;
font-size: 1.25rem;
font-weight: 650;
line-height: 1.3;
}
.ap-modal__desc {
margin: -8px 0 4px;
color: var(--ap-text-3);
line-height: 1.5;
}
.ap-modal__close {
display: grid;
place-items: center;
width: 40px;
height: 40px;
flex-shrink: 0;
padding: 0;
border: 0;
border-radius: var(--ap-radius-sm);
background: transparent;
color: var(--ap-text-2);
cursor: pointer;
}
.ap-modal__close:hover {
background: var(--ap-surface-2);
color: var(--ap-text);
}
.ap-modal__close:focus-visible {
outline: 2px solid var(--ap-focus);
outline-offset: 2px;
}
.ap-modal__close svg {
width: 20px;
height: 20px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
}
.ap-modal__actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
margin-top: 4px;
}
@media (prefers-reduced-motion: reduce) {
.ap-modal,
.ap-modal::backdrop { transition: none; }
}
/**
* Modal dialog on the native dialog element.
*
* Markup: [data-ap-modal] holding a trigger button[data-ap-open], a
* dialog[aria-labelledby][aria-describedby] with a form[method=dialog], and a
* role=status paragraph for the outcome. Add data-light-dismiss to the root
* to also close the dialog when its backdrop is clicked.
*/
export function init(root) {
const opener = root.querySelector("[data-ap-open]");
const dialog = root.querySelector("dialog");
const status = root.querySelector("[role=status]");
const html = document.documentElement;
let scrollWas = "";
function open() {
scrollWas = html.style.overflow;
html.style.overflow = "hidden";
status.textContent = "";
dialog.showModal();
}
function onClose() {
html.style.overflow = scrollWas;
if (dialog.returnValue === "save") status.textContent = "Profile saved";
dialog.returnValue = "";
// Browsers do this for modal dialogs; older engines need telling.
opener.focus();
}
function onClick(event) {
if (event.target.closest("[data-ap-close]")) {
dialog.close();
return;
}
// A click on the dialog element itself, not its content, is the backdrop.
if (event.target === dialog && root.hasAttribute("data-light-dismiss")) dialog.close();
}
opener.addEventListener("click", open);
dialog.addEventListener("click", onClick);
dialog.addEventListener("close", onClose);
return () => {
if (dialog.open) dialog.close();
html.style.overflow = scrollWas;
opener.removeEventListener("click", open);
dialog.removeEventListener("click", onClick);
dialog.removeEventListener("close", onClose);
};
}
for (const root of document.querySelectorAll("[data-ap-modal]")) init(root);
/* Design tokens for the pattern components. Light by default, dark when the
system asks for it; set data-theme="dark" on :root to force dark. */
:root {
--ap-radius-sm: 8px;
--ap-radius: 12px;
--ap-radius-lg: 16px;
--ap-radius-full: 999px;
--ap-font: "Instrument Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--ap-mono: "JetBrains Mono", ui-monospace, "Cascadia Mono", "SF Mono", Consolas, monospace;
--ap-ease: cubic-bezier(.2, .8, .2, 1);
--ap-duration: 180ms;
--ap-target: 44px;
--ap-bg: #F4F4F5;
--ap-surface: #FFFFFF;
--ap-surface-2: #F4F4F5;
--ap-surface-3: #E4E4E7;
--ap-border: #E4E4E7;
--ap-border-strong: #76767F;
--ap-text: #18181B;
--ap-text-2: #3F3F46;
--ap-text-3: #5E5E66;
--ap-accent: #4F46E5;
--ap-accent-hover: #4338CA;
--ap-on-accent: #FFFFFF;
--ap-accent-text: #4338CA;
--ap-accent-soft: #EEF2FF;
--ap-accent-soft-text: #3730A3;
--ap-focus: #4F46E5;
--ap-danger: #B91C1C;
--ap-danger-soft: #FEF2F2;
--ap-success: #15803D;
--ap-success-soft: #F0FDF4;
--ap-warning: #A15C07;
--ap-warning-soft: #FEFCE8;
--ap-info: #1D4ED8;
--ap-info-soft: #EFF6FF;
--ap-scrim: rgb(9 9 11 / .48);
--ap-shadow-sm: 0 1px 2px rgb(9 9 11 / .06);
--ap-shadow-md: 0 1px 2px rgb(9 9 11 / .05), 0 6px 16px -4px rgb(9 9 11 / .1);
--ap-shadow-lg: 0 2px 6px rgb(9 9 11 / .06), 0 20px 40px -12px rgb(9 9 11 / .22);
--ap-light-bg: #F4F4F5;
--ap-light-surface: #FFFFFF;
--ap-light-surface-2: #F4F4F5;
--ap-light-surface-3: #E4E4E7;
--ap-light-border: #E4E4E7;
--ap-light-border-strong: #76767F;
--ap-light-text: #18181B;
--ap-light-text-2: #3F3F46;
--ap-light-text-3: #5E5E66;
--ap-light-accent: #4F46E5;
--ap-light-accent-hover: #4338CA;
--ap-light-on-accent: #FFFFFF;
--ap-light-accent-text: #4338CA;
--ap-light-accent-soft: #EEF2FF;
--ap-light-accent-soft-text: #3730A3;
--ap-light-focus: #4F46E5;
--ap-light-danger: #B91C1C;
--ap-light-danger-soft: #FEF2F2;
--ap-light-success: #15803D;
--ap-light-success-soft: #F0FDF4;
--ap-light-warning: #A15C07;
--ap-light-warning-soft: #FEFCE8;
--ap-light-info: #1D4ED8;
--ap-light-info-soft: #EFF6FF;
--ap-light-scrim: rgb(9 9 11 / .48);
--ap-light-shadow-sm: 0 1px 2px rgb(9 9 11 / .06);
--ap-light-shadow-md: 0 1px 2px rgb(9 9 11 / .05), 0 6px 16px -4px rgb(9 9 11 / .1);
--ap-light-shadow-lg: 0 2px 6px rgb(9 9 11 / .06), 0 20px 40px -12px rgb(9 9 11 / .22);
--ap-dark-bg: #09090B;
--ap-dark-surface: #18181B;
--ap-dark-surface-2: #27272A;
--ap-dark-surface-3: #3F3F46;
--ap-dark-border: #2E2E33;
--ap-dark-border-strong: #8E8E97;
--ap-dark-text: #FAFAFA;
--ap-dark-text-2: #D4D4D8;
--ap-dark-text-3: #A1A1AA;
--ap-dark-accent: #818CF8;
--ap-dark-accent-hover: #A5B4FC;
--ap-dark-on-accent: #0C0A1F;
--ap-dark-accent-text: #A5B4FC;
--ap-dark-accent-soft: #1E1B4B;
--ap-dark-accent-soft-text: #C7D2FE;
--ap-dark-focus: #A5B4FC;
--ap-dark-danger: #F87171;
--ap-dark-danger-soft: #2A1215;
--ap-dark-success: #4ADE80;
--ap-dark-success-soft: #0F2A1A;
--ap-dark-warning: #FACC15;
--ap-dark-warning-soft: #2A2410;
--ap-dark-info: #60A5FA;
--ap-dark-info-soft: #0F1D33;
--ap-dark-scrim: rgb(0 0 0 / .62);
--ap-dark-shadow-sm: 0 1px 2px rgb(0 0 0 / .4);
--ap-dark-shadow-md: 0 1px 2px rgb(0 0 0 / .4), 0 8px 20px -6px rgb(0 0 0 / .5);
--ap-dark-shadow-lg: 0 2px 8px rgb(0 0 0 / .45), 0 24px 48px -12px rgb(0 0 0 / .7);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ap-bg: #09090B;
--ap-surface: #18181B;
--ap-surface-2: #27272A;
--ap-surface-3: #3F3F46;
--ap-border: #2E2E33;
--ap-border-strong: #8E8E97;
--ap-text: #FAFAFA;
--ap-text-2: #D4D4D8;
--ap-text-3: #A1A1AA;
--ap-accent: #818CF8;
--ap-accent-hover: #A5B4FC;
--ap-on-accent: #0C0A1F;
--ap-accent-text: #A5B4FC;
--ap-accent-soft: #1E1B4B;
--ap-accent-soft-text: #C7D2FE;
--ap-focus: #A5B4FC;
--ap-danger: #F87171;
--ap-danger-soft: #2A1215;
--ap-success: #4ADE80;
--ap-success-soft: #0F2A1A;
--ap-warning: #FACC15;
--ap-warning-soft: #2A2410;
--ap-info: #60A5FA;
--ap-info-soft: #0F1D33;
--ap-scrim: rgb(0 0 0 / .62);
--ap-shadow-sm: 0 1px 2px rgb(0 0 0 / .4);
--ap-shadow-md: 0 1px 2px rgb(0 0 0 / .4), 0 8px 20px -6px rgb(0 0 0 / .5);
--ap-shadow-lg: 0 2px 8px rgb(0 0 0 / .45), 0 24px 48px -12px rgb(0 0 0 / .7);
}
}
:root[data-theme="dark"] {
--ap-bg: #09090B;
--ap-surface: #18181B;
--ap-surface-2: #27272A;
--ap-surface-3: #3F3F46;
--ap-border: #2E2E33;
--ap-border-strong: #8E8E97;
--ap-text: #FAFAFA;
--ap-text-2: #D4D4D8;
--ap-text-3: #A1A1AA;
--ap-accent: #818CF8;
--ap-accent-hover: #A5B4FC;
--ap-on-accent: #0C0A1F;
--ap-accent-text: #A5B4FC;
--ap-accent-soft: #1E1B4B;
--ap-accent-soft-text: #C7D2FE;
--ap-focus: #A5B4FC;
--ap-danger: #F87171;
--ap-danger-soft: #2A1215;
--ap-success: #4ADE80;
--ap-success-soft: #0F2A1A;
--ap-warning: #FACC15;
--ap-warning-soft: #2A2410;
--ap-info: #60A5FA;
--ap-info-soft: #0F1D33;
--ap-scrim: rgb(0 0 0 / .62);
--ap-shadow-sm: 0 1px 2px rgb(0 0 0 / .4);
--ap-shadow-md: 0 1px 2px rgb(0 0 0 / .4), 0 8px 20px -6px rgb(0 0 0 / .5);
--ap-shadow-lg: 0 2px 8px rgb(0 0 0 / .45), 0 24px 48px -12px rgb(0 0 0 / .7);
}
WCAG 2.2
What it meets
The success criteria this pattern takes care of, and how.
-
1.3.1 Info and Relationships
Level A
The dialog has a name from its heading and a description from its first paragraph, so the relationship is exposed, not implied.
-
2.1.1 Keyboard
Level A
Every control, including closing, works from the keyboard; Escape is handled by the browser.
-
2.1.2 No Keyboard Trap
Level A
Focus is held in the dialog while it is open, but Escape, Cancel and the close button always let it out.
-
2.4.3 Focus Order
Level A
Focus moves to the first field on open and back to the trigger on close, so the reading order follows what is on screen.
-
2.4.7 Focus Visible
Level AA
Every control shows a visible ring when it has keyboard focus.
-
4.1.2 Name, Role, Value
Level A
role=dialog and aria-modal come from the dialog element; the trigger says it opens a dialog.
-
4.1.3 Status Messages
Level AA
Saving is confirmed in a status message that screen readers announce without moving focus.
Usage
When to use it
Use it
- A short task that has to be finished or dismissed before going on, like editing one record or confirming a choice.
- Content that needs the full attention of the person, briefly.
Use something else
- Long forms or anything people need to look things up for: give it a page.
- Messages that need no answer: use a toast or a status message.
- Information to consult while working on the page: use a non-modal dialog or a drawer.
Common failures
How it usually goes wrong
A div styled to look like a dialog
Without the dialog element or role=dialog plus aria-modal, a screen reader can wander out into the page behind, which it cannot see.
Focus left on the page behind
If focus does not move into the dialog, keyboard users keep tabbing through hidden content, and screen reader users never hear it opened.
Focus lost on close
When the dialog closes and focus goes to the top of the page, people have to find their way back. Return it to the trigger.
No way out
A dialog that ignores Escape and has no visible close button traps everyone who cannot click outside it.
An unnamed dialog
"Dialog" on its own tells nobody what it is for. aria-labelledby pointing at the heading names it.
The page scrolls underneath
Scrolling the page behind a modal is disorienting on touch screens. The page is locked while the dialog is open.
Notes
Building it
- Open it with showModal(), not show(): only the modal version makes the rest of the page inert and puts the dialog in the top layer.
- The autofocus attribute on the first field decides where focus lands. For a destructive confirmation, put it on the safest button instead.
- Browsers return focus to the previously focused element when a modal dialog closes; the script does it as well for older engines.
- The close-on-outside-click option only adds a way to close; Escape and the visible buttons still work, so nobody depends on a pointer.
Sources: WAI-ARIA Authoring Practices: Dialog (Modal) · HTML: the dialog element
Checked with axe in light and dark themes, at desktop and phone widths, and by keyboard. Not yet tested with every screen reader and browser pair. Report a correction