Modal Dialog
Surfaces
Open in Storybook: Modal Dialog (opens in a new tab)
Overview
A native dialog for a modal that stops the page and waits for an answer the person just asked for. The browser makes the page behind it inert, draws the backdrop, closes on Esc and returns focus to the opener. It also draws a drawer beside the page.
The component adds the heading that names it, a 44 × 44 Close button, declared initial focus and a custom focus trap. That one is my choice, and it is explained under Focus trap.
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.
Usage
Variants
alertdialog only when the answer is required to finish what was just started. If the flow can go on without it, a dialog: one attribute apart.
Confirm a destructive action
Buttons named by action: “Delete draft”, never “OK”.
closedby="any"·initialFocusFocus lands on Cancel.
Unsaved changes
Two labelled exits and no Close. The safe one keeps the work.
role="alertdialog"·closedby="none"·showCloseButton={false}Focus lands on Keep editing.
Critical failure
Retry runs inside and doesn’t close. After the last attempt only Work offline is left.
role="alertdialog"·closedby="none"Focus lands on Retry.
Permission pre-prompt
Say what, why, where it stays and what happens if no. The browser prompt comes from Allow.
role="alertdialog"Focus lands on Not now.
Terms or consent gate
No Close button. The terms scroll by keyboard.
role="alertdialog"·closedby="none"·showCloseButton={false}Focus lands on Decline.
Progress that must finish
No exit while it runs. Close appears when it is done.
closedbynone, thencloserequestFocus lands on the heading.
Settings, picker, form
Cancel is
formnovalidate: a required field blocks Save, never an exit.closedby="closerequest"Focus lands on the heading.
Media viewer
Named by its caption. Close is the only chrome, so touch, voice and switch users have an exit.
ariaLabelledBy·initialFocus="[data-media]"Focus lands on the media.
Drawer: filters, history
The same dialog beside the page, with the same head, exits and focus.
variant="drawer"·positionFocus lands on the heading.
Props
| Prop | Does | Default |
|---|---|---|
id | Required. The opener’s commandfor points to it | — |
title | Names the dialog: its h2 is what aria-labelledby points to and what takes focus on open | — |
ariaLabelledBy, ariaLabel | Name it when there is no title | — |
aria-describedby | Names the paragraph that says what happens or how to leave | — |
role | alertdialog only when the answer is required to finish what was just started | dialog |
closedby | any: Esc and a click on the backdrop. closerequest: Esc only. none: an answer is required, and the content owes labelled exits | any |
showCloseButton, closeLabel | The 44 × 44 Close button and its name | true, Close dialog |
initialFocus | A selector for the element that takes focus on open, such as the safe button of an alertdialog. Never Close | The heading, or an element with autofocus |
open, onClose(returnValue) | Control it from state. Without open, a button with command="show-modal" and commandfor opens it, with no script | — |
variant, position | drawer slides in from left, right, bottom or left-bottom | modal, right |
restoreFocusRef | The element that takes focus on close instead of the opener | The opener |
ModalDialogButton · kind | primary is the answer, secondary the way out, danger an answer that destroys. 47px tall, never under 44px | secondary |
Example
A button opens it with the invoker command, no script. Each button of the form gives its value as the returnValue that onClose receives.
import { ModalDialog } from '@/components/ModalDialog'
<button type="button" {...{ command: 'show-modal', commandfor: 'del' }}>
Delete draft
</button>
<ModalDialog
id="del"
title="Delete this draft?"
onClose={(answer) => answer === 'delete' && removeDraft()}
>
<p>“Spring menu” and its 3 photos will be deleted.</p>
<form method="dialog">
<button value="cancel">Cancel</button>
<button value="delete">Delete draft</button>
</form>
</ModalDialog>Viewports
Each variant is a story in Storybook. Pick the width in the viewport menu of the toolbar, for any story: 640 CSS px · 200% zoom, 390 · mobile or 320 CSS px · 400% zoom.
| Width | What the component does |
|---|---|
| 1280 CSS px · 100% | Centred, up to 32rem |
| 640 CSS px · 200% | The width minus 1rem; the buttons stay side by side if they fit |
| 390 · mobile | The width minus 1rem; the buttons stack, in the order they are read |
| 320 CSS px · 400% (1.4.10) | The whole screen; the panel scrolls inside |
| Text 200% (1.4.4) | The height follows the text; nothing is clipped |
Behavior
| Moment | What the component does |
|---|---|
| Opens | From a button with command="show-modal" and commandfor, or from open |
| Closes | Esc and the backdrop (closedby), the Close button, a button of a form method="dialog", command="close" |
| Answer | onClose(returnValue) runs for every exit. A button of a form method="dialog" gives its value; an exit with no answer (Esc, the backdrop, Close) gives "". Chromium keeps the last answer on a backdrop click and WebKit on Esc, so the component clears it on cancel and on the close command |
| Scroll | Locked while it is open and released on every exit, by CSS |
| Stacked | Siblings in the DOM, never nested. The first asks in the second before it lets go of work, and every exit of the first goes through the same guard |
Accessibility
Focus management
| Moment | What the component does |
|---|---|
| Open | Declared, not set: autofocus goes on the heading or on the element initialFocus names, and showModal() honours it. There is no requestAnimationFrame and no frame to wait for, so a key pressed right after opening is not undone. Never on Close: undeclared, the browser focuses the first focusable element, which is Close when it comes first |
| Return | To the opener from every exit, by the browser. restoreFocusRef names another element |
| Trap | Tab wraps inside the dialog: see Focus trap |
Focus trap
Disclaimer: This is MY decision. The dialog tag has a native focus trap, but it considers the interactive UI widgets of the browser, such as its address bar and its toolbar, part of the focus flow. In my opinion this is a blocker for keyboard and screen reader users: if a dialog contains 3 interactive elements, they also have to navigate through the interactive elements of the browser, and their experience gets worse.
I decided to keep the browser interface out of the focus flow, so I implemented a custom focus trap. It is not something the platform asks for: it is mine, and it is the only place in the component that moves focus with a script.
- It is a
keydownlistener on the dialog. Tab on the last control goes to the first one, and Shift+Tab on the first control goes to the last one, so Tab never leaves the dialog for the browser. - It does only that. No other key changes, and Esc still closes the dialog when
closedbyallows. - The user is able to reach the interactive UI widgets with the following key combination Ctrl/Cmd+L and F6.
Keyboard interaction
| Key | Does |
|---|---|
| Tab, Shift+Tab | Move through the controls inside and wrap: see Focus trap |
| Esc | Closes it when closedby allows. Focus returns to the opener |
| Enter, Space | On the opener, opens it. On a button of a form method="dialog", submits and closes |
Screen reader
- It is heard by its name and role: “Delete this draft?, dialog”. The Close button is named “Close” and its glyph is
aria-hidden. - 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. It is called on the dialog or on an element inside it, once per event, after the focus has moved: the page behind is inert, and a call on an inert element says nothing. WhereariaNotifyis missing, nothing is said and nothing replaces it. - The buttons say what they do: “Delete draft”, never “OK”. The safe one comes first.
Related
References
This section is still in progress.