Modal Dialog
Built with: dialog · showModal() · command=show-modal · closedby · form method=dialog
Open in Storybook: Modal Dialog (opens in a new tab)
Overview
Native dialog opened with showModal(), for an answer the person just asked for.
How it works
showModal() brings the top layer, ::backdrop, an inert page, Esc and focus return. Modal only: non-modal dialogs, popovers, toasts and banners are not covered.
- Use it when the page must stop for an answer the person just asked for.
- Never on page load or a timer: it steals the caret from someone reading.
- Don’t ask when nothing changed, or for a choice that can be undone: do it and offer Undo.
- Extra detail that can sit beside the content is a details or a toggletip.
Types
alertdialog only when the answer is required to finish what was just started. If the flow can go on without it, dialog: one attribute apart.
| Type | Role · closedby | Focus lands on | Notes |
|---|---|---|---|
| Confirm a destructive action | dialog · any | Cancel | Buttons named by action: “Delete draft”, never “OK”. |
| Unsaved changes | alertdialog · none | Keep editing | Two labelled exits, no Close. cancel is guarded. |
| Critical failure | alertdialog · none | Retry | Retry runs inside and doesn’t close. The last failure disables it and moves focus to Work offline. |
| Permission pre-prompt | alertdialog | Not now | Say what, why, where it stays and what happens if no. The browser prompt comes from Allow. |
| Terms or consent gate | alertdialog · none | Decline | No Close button. |
| Progress that must finish | dialog · none, then closerequest | The heading | No exit while it runs. Close appears when it is done. |
| Settings, picker, form | dialog · any or closerequest | The heading | cancel is guarded when dirty. Cancel is formnovalidate. |
| Media viewer | dialog · any | The media | Named by its caption. Close is the only chrome: Esc is not the only exit. |
| Drawer: filters, history | dialog · any | The heading | The same modal pinned to a side. |
Live example
Anatomy
- Opener
button command="show-modal" commandforOpens with no JS. Focus returns to it from every exit.
- Dialog
dialog closedbyTop layer, inert page, Esc.
- Name
h2+aria-labelledbyHeard as “Delete this draft?, dialog”.
- Close button
button command="close" aria-label="Close"Only when leaving costs nothing.
- Description
p+aria-describedbyWhat happens, how to leave.
- Actions
buttoninform method="dialog"Named by action: “Delete draft”, never “OK”.
- Backdrop
::backdropDims the page.
inertdoes the work.
Specs
Measurements
| Part | Property | Value |
|---|---|---|
| Panel | Width | max-inline-size: 32rem, centred. 480px in the design |
| Panel | Padding · gap | 24px · 12px |
| Header | Height · gap | 44px · 12px |
| Title | Text | Work Sans bold, 20px / 120% |
| Description | Text | Work Sans regular, 15px / 145% |
| Actions | Gap | 12px |
| Close button | Size · corner | 44 × 44px · 8px |
| Close button | Glyph | 18px, 3px stroke, aria-hidden |
| Button | Size | 47px tall, padding 11px 20px. 44px minimum |
| Button | Corner · border | 6px · 1.5px |
| Button | Label | Work Sans bold, 15px / 145% |
| Focus ring | Width · gap | 3px · 3px off the button. Close draws it inside its 44px. On buttons, Close and the heading |
| Motion | Duration | 0.2s, opacity and translate of 1rem. 1ms under reduced motion |
Adapting to viewports
The panel shrinks to the viewport, then fills it and scrolls inside. Close and the actions stay reachable; the page behind never scrolls.
| Width | Panel | What changes |
|---|---|---|
| 1280 CSS px · 100% | Centred, max-inline-size: 32rem | Backdrop over the inert page; scroll locked |
| 640 CSS px · 200% | Full width minus 1rem | Buttons stay side by side if they fit |
| 390 · mobile | Full width minus 1rem, max-block-size: 100dvh − 2rem | Buttons stack, the safe one first. A visible Close: touch has no Esc |
| 320 CSS px · 400% (1.4.10) | Fullscreen: inset: 0; margin: 0; 100dvh | The panel scrolls inside; a scrolling region with no control has tabindex="0" |
| Text 200% (1.4.4) | Height from content | Heading and buttons wrap; nothing is clipped by a fixed height |
Motion
Animate in and out without breaking the exit or reduced motion.
dialog[open] { display: grid; } /* display only when open */
dialog, dialog::backdrop {
transition: opacity .2s, translate .2s, overlay .2s allow-discrete, display .2s allow-discrete;
}
dialog:not([open]) { opacity: 0; translate: 0 1rem; }
@starting-style { dialog[open] { opacity: 0; translate: 0 1rem; } }
@media (prefers-reduced-motion: reduce) {
dialog, dialog::backdrop {
transition: opacity 1ms, overlay 1ms allow-discrete, display 1ms allow-discrete; /* keep display and overlay */
translate: none;
}
}Markup
<button command="show-modal" commandfor="del" id="opener">Delete draft</button>
<dialog id="del" closedby="any" aria-labelledby="del-h" aria-describedby="del-d">
<h2 id="del-h">Delete this draft?</h2>
<p id="del-d">“Spring menu” and its 3 photos will be deleted. You can’t undo this.</p>
<form method="dialog">
<button value="cancel" autofocus>Cancel</button>
<button value="delete">Delete draft</button>
</form>
</dialog>Script
HTML first, then the script for what native does not do: the opener when a key or state opens it, the Tab wrap (a custom focus trap), the cancel guard and returnValue.
const dialog = document.getElementById('del')
const leave = document.getElementById('leave') // the sibling alertdialog
let dirty = false
let opener // only when a key or state opens it
const open = () => { opener = document.activeElement; dialog.showModal() }
// Tab wrap, a custom focus trap: wrap only, nothing more
dialog.addEventListener('keydown', (e) => {
if (e.key !== 'Tab' || e.target.matches('video, audio')) return
const f = [...dialog.querySelectorAll('button, input, select, textarea, a[href], [tabindex="0"]')]
.filter((el) => !el.disabled)
if (e.shiftKey && e.target === f[0]) { e.preventDefault(); f.at(-1).focus() }
else if (!e.shiftKey && e.target === f.at(-1)) { e.preventDefault(); f[0].focus() }
})
// Guard: ask once, in a sibling dialog. Close calls the same guard.
const guard = (e) => { if (dirty) { e.preventDefault(); leave.showModal() } }
dialog.addEventListener('cancel', guard)
dialog.addEventListener('command', (e) => e.command === 'close' && guard(e))
dialog.addEventListener('close', () => {
if (dialog.returnValue === 'delete') removeDraft() // the exact word
opener?.focus()
})Tokens
Component tokens
The values of the Figma components Dialog / Button and Dialog / Close button.
| Part | Property | Token | Value |
|---|---|---|---|
| Panel | Fill | white | #ffffff |
| Panel | Corner | — | 12px (radius is 8px) |
| Panel | Shadow | — | 0 12px 32px, 25% black |
| Backdrop | Colour | ink at 55% | rgb(22 22 26 / .55) |
| Title | Colour | ink | #16161a |
| Description | Colour | ink-soft | #4b4b55 |
| Close glyph | Colour | ink | #16161a |
| Close button | Fill | white | #ffffff |
| Close button | Corner | radius | 8px |
| Button, primary | Fill and border | lilac | #5e548e |
| Button, primary | Label | white | #ffffff |
| Button, secondary | Fill | white | #ffffff |
| Button, secondary | Border and label | ink | #16161a |
| Button, danger | Fill and border | — | #c8102e (danger is #82002a) |
| Button, danger | Label | white | #ffffff |
| Focus ring | Colour | ink | #16161a, 3px |
Guidelines
Ways to open and close
Native first. Script only when state, a key or a promise opens it.
| Way | Use when | Notes |
|---|---|---|
command="show-modal" + commandfor="id" on a button | Default | No JS, focus returns. Fallback: 'command' in HTMLButtonElement.prototype, else a click handler |
dialog.showModal() | State, a key or a promise opens it | Save the opener first |
command="close" or dialog.close(value) | Cancel buttons, script exits | Never fires cancel, never validates |
<form method="dialog"> + submit | Submit is the close | returnValue is the submitter’s value |
command event on the dialog | Guard a close | event.command; preventDefault() stops it |
closedby
How much it costs to leave decides which exits exist.
| closedby | Esc | Backdrop | Use |
|---|---|---|---|
any | closes | closes | Leaving costs nothing: confirm, picker |
closerequest | closes | nothing | A stray click would lose input; disposable sub-dialogs |
none | nothing | nothing | Answer required or work in progress. Owes labelled exits |
Guarding a close
| Rule | How |
|---|---|
| Only when closing loses work | Multi-step, critical, dirty: preventDefault() on cancel, then ask once in a sibling dialog, with exits named by what they do. Never a handler that keeps refusing |
| Every exit runs the guard | cancel fires on Esc and an any backdrop click, never on close(): the Close button calls the same guard |
| The safe answer | Keeps the work: it gets focus and the primary style |
No closedby support | 'closedBy' in HTMLDialogElement.prototype is false? Also preventDefault() on cancel |
| Media inside | pause() in close, beside the scroll unlock |
returnValue
A string, "" at first. close(100) gives "100"; objects go through JSON.stringify and JSON.parse.
| Rule | Why |
|---|---|
| Every exit gets its own value | A bare close() and command="close" keep the previous one |
| Esc and a backdrop click | Not reliable: Chromium clears the value on Esc and keeps the last answer on a backdrop click, WebKit keeps it on Esc (measured). Clear it in the cancel listener: dialog.returnValue = '' |
| Compare the exact word | === 'delete', never if (returnValue): “cancel” is truthy |
Read it in close | Every exit ends there. To await an answer, resolve a promise from a one-time close listener, then showModal(). In React, flushSync state first if the copy depends on the last answer |
Forms
A form first: required fields block a submit, never an exit.
| Rule | How |
|---|---|
| Submit is the close | <form method="dialog">. An empty required field blocks it |
| Cancel, save draft | formnovalidate, or a plain-submit Cancel is blocked too. close(), Esc and command="close" skip validation |
| Errors | Cancel invalid, write the message under the field (aria-describedby, aria-invalid), focus the first failing field |
| Enter | Presses the first submit in the DOM: a left-hand dismiss is type="button" |
| Must stay open | Steps, a result view, a pending send: plain <form onSubmit> + preventDefault(). Only the last step closes natively |
Stacked modals
Siblings in the DOM, never nested. Call order is the stack.
| Rule | How |
|---|---|
| Order | Esc closes the top one; focus returns to the button that opened it; the answer comes back as returnValue on close |
| Backdrops add up | Two at 0.55 leave about 0.80, and a blur blurs the blur: make the second lighter |
| Text that changes underneath | Said with its own ariaNotify() call, on that dialog |
| Popovers | One outside the modal’s subtree is inert while it is open: nest it inside, or close the modal first |
Accessibility
Requirements
| WCAG | Requirement |
|---|---|
| 4.1.2 · 2.4.3 | showModal() or command="show-modal", never the open attribute: that is a non-modal (no top layer, inert, backdrop or Esc) |
| 4.1.2 · 1.3.1 | Named with aria-labelledby (the heading); aria-label only without one. What happens and how to leave: aria-describedby |
| 4.1.2 | No tabindex on the dialog. No role="dialog", aria-modal or aria-hidden on the page: native modality covers them |
| 2.1.2 | Esc closes. closedby="none" owes labelled exits |
| 1.4.10 | Scroll locked while open and released on every exit: :root:has(dialog:modal) { overflow: hidden; scrollbar-gutter: stable }. A scripted lock is ref-counted |
Focus management
| Moment | Rule |
|---|---|
| Open | Placed on every open, never on Close. Declare it with autofocus: no script, no frame to wait for. Undeclared, it lands on the first focusable element, which can be Close. Never in requestAnimationFrame: it doesn’t run in a hidden tab |
| Where | By type, see Types. A destructive or consent dialog gets the safe button: Enter on an unread panel must not consent or destroy. Steps: the step heading, on every change of view |
| Return | To the opener from every exit. Invoker commands do it; otherwise save document.activeElement before showModal(). Opened by a key, body is not an opener: save the element, or focus the page heading if it is gone |
| Trap | Tab wraps inside the dialog: see Focus trap |
| Closed | Out of the tab order: display only on dialog[open]. A display: flex on the base rule beats dialog:not([open]) and leaves an invisible form tabbable |
Focus trap
My choice. The native modal considers the interactive widgets of the browser, such as its address bar and its toolbar, part of the focus flow: after the last control of the dialog, Tab goes out to the browser interface. In my opinion this is a blocker for keyboard and screen reader users, who have to cross it every time. I decided to keep the browser interface out of the focus flow, so the wrap is a custom focus trap that I wrote on purpose.
| Situation | Result |
|---|---|
| Tab on the last control | Goes to the first control of the dialog, never to the browser interface |
| Shift+Tab on the first control | Goes to the last control |
| Ctrl/Cmd+L, F6 | Still reach the browser interface: no page can block them (whatwg/html#8339) |
Focus inside video or audio | The wrap is off: their controls are the browser’s, and it would pin the user on the first control |
Keyboard
| Key | Does |
|---|---|
| Tab, Shift+Tab | Moves inside the dialog and wraps: see Focus trap |
| Esc | Closes if closedby allows. Focus returns to the opener |
| Enter | Activates the focused control. In a method="dialog" form it submits and closes |
Announcements
A message nothing on the page moves for is said with ariaNotify(), not a live region. The same words are on the page as text. Called once per event, after the focus has moved, on the dialog or an element inside it: a call on the inert page says nothing. Where ariaNotify is missing, nothing is said and nothing replaces it.
| Event | Said on | Announced | Priority |
|---|---|---|---|
| An attempt fails | The dialog | "Attempt 2 of 3 failed." | normal |
| The last attempt fails | The dialog, after focus moves to Work offline | "Attempt 3 of 3 failed. Work offline: your order stays on this device." | high: the task stopped and the next step is the person’s |
| The progress is done | The progress bar | "3 of 3 uploaded. You can close this dialog." | normal |
| Retrying, each photo, opening | Not said | The text is on the page, or focus and the name say it | — |
WCAG criteria
| Success criterion | Level | Met by |
|---|---|---|
| 1.3.1 Info and Relationships | A | Named by its heading (aria-labelledby), described by aria-describedby |
| 1.3.2 Meaningful Sequence | A | DOM order = visual order; actions in the order they are read |
| 1.4.3 Contrast (Minimum) | AA | Text in the panel 4.5:1 over the panel, not the backdrop |
| 1.4.4 Resize Text | AA | Panel height from content; nothing clipped at 200% |
| 1.4.10 Reflow | AA | Fullscreen at 320 px; the panel scrolls inside, the page is locked |
| 1.4.11 Non-text Contrast | AA | Buttons, Close and the focus ring 3:1 |
| 2.1.1 Keyboard | A | Every exit reachable by keyboard |
| 2.1.2 No Keyboard Trap | A | Esc, or labelled exits when closedby="none" |
| 2.1.4 Character Key Shortcuts | A | A one-key opener is switchable, remappable or scoped |
| 2.3.3 Animation from Interactions | AAA | prefers-reduced-motion removes translate and scale |
| 2.4.3 Focus Order | A | Initial focus placed on every open; focus returns to the opener from every exit |
| 2.4.7 Focus Visible | AA | 3px ring on heading, buttons and media |
| 2.4.11 Focus Not Obscured (Minimum) | AA | Nothing sits over the focused control inside the panel |
| 2.5.8 Target Size (Minimum) | AA | Close and buttons 44 × 44 |
| 3.2.1 On Focus | A | Never opened by focus, a timer or page load |
| 3.3.1 Error Identification | A | Form errors under the field, inside the panel |
| 3.3.4 Error Prevention (Legal, Financial, Data) | AA | Confirm destructive actions; the safe answer is focused |
| 4.1.2 Name, Role, Value | A | Native dialog role and modality; alertdialog only when required |
| 4.1.3 Status Messages | AA | The words are on the page as text and ariaNotify() says them from inside the panel |
Related
References
The post is still in progress.