Design System

Design System

Modal Dialog

Surfaces

Open in Storybook: Modal Dialog (opens in a new tab)

On this page

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

  1. Opener

    button command="show-modal" commandfor

    Opens with no JS. Focus returns to it from every exit.

  2. Dialog

    dialog closedby

    Top layer, inert page, Esc.

  3. Name

    h2 + aria-labelledby

    Heard as “Delete this draft?, dialog”.

  4. Close button

    button command="close" aria-label="Close"

    Only when leaving costs nothing.

  5. Description

    p + aria-describedby

    What happens, how to leave.

  6. Actions

    button in form method="dialog"

    Named by action: “Delete draft”, never “OK”.

  7. Backdrop

    ::backdrop

    Dims the page. inert does 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" · initialFocus

    Focus lands on Cancel.

    Delete this draft?

    “Spring menu” and its 3 photos will be deleted. You can’t undo this.

  • 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.

    Leave without saving?

    Your changes to “Spring menu” will be lost.

  • 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.

    Couldn’t send your order

    The connection dropped. Your order is still here.

  • 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.

    Use your location?

    To show boutiques near you. It stays on this device. Without it, you can search by city.

  • Terms or consent gate

    No Close button. The terms scroll by keyboard.

    role="alertdialog" · closedby="none" · showCloseButton={false}

    Focus lands on Decline.

    Accept the updated terms

    Read the changes before you continue.

    1. What changed. We now store your order history for 24 months, so you can repeat an order in one step.

    2. Your rights. You can ask for a copy or deletion at any time, from your account or by writing to us.

    3. Cookies. Only the ones that keep you signed in are set until you choose.

  • Progress that must finish

    No exit while it runs. Close appears when it is done.

    closedby none, then closerequest

    Focus lands on the heading.

    Uploading 3 photos

    0 of 3 uploaded

  • Settings, picker, form

    Cancel is formnovalidate: a required field blocks Save, never an exit.

    closedby="closerequest"

    Focus lands on the heading.

    Delivery address

    Example: Via Roma 12

    Example: Monza

  • 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.

    Ristretto capsule, top view

    Esc to close

  • Drawer: filters, history

    The same dialog beside the page, with the same head, exits and focus.

    variant="drawer" · position

    Focus lands on the heading.

    Filters

    Size · Roast · Intensity

Props

Props
PropDoesDefault
idRequired. The opener’s commandfor points to it—
titleNames the dialog: its h2 is what aria-labelledby points to and what takes focus on open—
ariaLabelledBy, ariaLabelName it when there is no title—
aria-describedbyNames the paragraph that says what happens or how to leave—
rolealertdialog only when the answer is required to finish what was just starteddialog
closedbyany: Esc and a click on the backdrop. closerequest: Esc only. none: an answer is required, and the content owes labelled exitsany
showCloseButton, closeLabelThe 44 × 44 Close button and its nametrue, Close dialog
initialFocusA selector for the element that takes focus on open, such as the safe button of an alertdialog. Never CloseThe 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, positiondrawer slides in from left, right, bottom or left-bottommodal, right
restoreFocusRefThe element that takes focus on close instead of the openerThe opener
ModalDialogButton · kindprimary is the answer, secondary the way out, danger an answer that destroys. 47px tall, never under 44pxsecondary

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.

Viewports
WidthWhat 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 · mobileThe 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

Behavior
MomentWhat the component does
OpensFrom a button with command="show-modal" and commandfor, or from open
ClosesEsc and the backdrop (closedby), the Close button, a button of a form method="dialog", command="close"
AnsweronClose(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
ScrollLocked while it is open and released on every exit, by CSS
StackedSiblings 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

Focus management
MomentWhat the component does
OpenDeclared, 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
ReturnTo the opener from every exit, by the browser. restoreFocusRef names another element
TrapTab 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 keydown listener 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 closedby allows.
  • The user is able to reach the interactive UI widgets with the following key combination Ctrl/Cmd+L and F6.

Keyboard interaction

Keyboard interaction
KeyDoes
Tab, Shift+TabMove through the controls inside and wrap: see Focus trap
EscCloses it when closedby allows. Focus returns to the opener
Enter, SpaceOn 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. Where ariaNotify is 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.