Design System

Design System

Modal Dialog

Built with: dialog · showModal() · command=show-modal · closedby · form method=dialog

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

On this page

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.

Types
TypeRole · closedbyFocus lands onNotes
Confirm a destructive actiondialog · anyCancelButtons named by action: “Delete draft”, never “OK”.
Unsaved changesalertdialog · noneKeep editingTwo labelled exits, no Close. cancel is guarded.
Critical failurealertdialog · noneRetryRetry runs inside and doesn’t close. The last failure disables it and moves focus to Work offline.
Permission pre-promptalertdialogNot nowSay what, why, where it stays and what happens if no. The browser prompt comes from Allow.
Terms or consent gatealertdialog · noneDeclineNo Close button.
Progress that must finishdialog · none, then closerequestThe headingNo exit while it runs. Close appears when it is done.
Settings, picker, formdialog · any or closerequestThe headingcancel is guarded when dirty. Cancel is formnovalidate.
Media viewerdialog · anyThe mediaNamed by its caption. Close is the only chrome: Esc is not the only exit.
Drawer: filters, historydialog · anyThe headingThe same modal pinned to a side.

Live example

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.

Specs

Measurements

Measurements
PartPropertyValue
PanelWidthmax-inline-size: 32rem, centred. 480px in the design
PanelPadding · gap24px · 12px
HeaderHeight · gap44px · 12px
TitleTextWork Sans bold, 20px / 120%
DescriptionTextWork Sans regular, 15px / 145%
ActionsGap12px
Close buttonSize · corner44 × 44px · 8px
Close buttonGlyph18px, 3px stroke, aria-hidden
ButtonSize47px tall, padding 11px 20px. 44px minimum
ButtonCorner · border6px · 1.5px
ButtonLabelWork Sans bold, 15px / 145%
Focus ringWidth · gap3px · 3px off the button. Close draws it inside its 44px. On buttons, Close and the heading
MotionDuration0.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.

Adapting to viewports
WidthPanelWhat changes
1280 CSS px · 100%Centred, max-inline-size: 32remBackdrop over the inert page; scroll locked
640 CSS px · 200%Full width minus 1remButtons stay side by side if they fit
390 · mobileFull width minus 1rem, max-block-size: 100dvh − 2remButtons stack, the safe one first. A visible Close: touch has no Esc
320 CSS px · 400% (1.4.10)Fullscreen: inset: 0; margin: 0; 100dvhThe panel scrolls inside; a scrolling region with no control has tabindex="0"
Text 200% (1.4.4)Height from contentHeading 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.

Component tokens
PartPropertyTokenValue
PanelFillwhite#ffffff
PanelCorner—12px (radius is 8px)
PanelShadow—0 12px 32px, 25% black
BackdropColourink at 55%rgb(22 22 26 / .55)
TitleColourink#16161a
DescriptionColourink-soft#4b4b55
Close glyphColourink#16161a
Close buttonFillwhite#ffffff
Close buttonCornerradius8px
Button, primaryFill and borderlilac#5e548e
Button, primaryLabelwhite#ffffff
Button, secondaryFillwhite#ffffff
Button, secondaryBorder and labelink#16161a
Button, dangerFill and border—#c8102e (danger is #82002a)
Button, dangerLabelwhite#ffffff
Focus ringColourink#16161a, 3px

Guidelines

Ways to open and close

Native first. Script only when state, a key or a promise opens it.

Ways to open and close
WayUse whenNotes
command="show-modal" + commandfor="id" on a buttonDefaultNo JS, focus returns. Fallback: 'command' in HTMLButtonElement.prototype, else a click handler
dialog.showModal()State, a key or a promise opens itSave the opener first
command="close" or dialog.close(value)Cancel buttons, script exitsNever fires cancel, never validates
<form method="dialog"> + submitSubmit is the closereturnValue is the submitter’s value
command event on the dialogGuard a closeevent.command; preventDefault() stops it

closedby

How much it costs to leave decides which exits exist.

closedby
closedbyEscBackdropUse
anyclosesclosesLeaving costs nothing: confirm, picker
closerequestclosesnothingA stray click would lose input; disposable sub-dialogs
nonenothingnothingAnswer required or work in progress. Owes labelled exits

Guarding a close

Guarding a close
RuleHow
Only when closing loses workMulti-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 guardcancel fires on Esc and an any backdrop click, never on close(): the Close button calls the same guard
The safe answerKeeps the work: it gets focus and the primary style
No closedby support'closedBy' in HTMLDialogElement.prototype is false? Also preventDefault() on cancel
Media insidepause() in close, beside the scroll unlock

returnValue

A string, "" at first. close(100) gives "100"; objects go through JSON.stringify and JSON.parse.

returnValue
RuleWhy
Every exit gets its own valueA bare close() and command="close" keep the previous one
Esc and a backdrop clickNot 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 closeEvery 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.

Forms
RuleHow
Submit is the close<form method="dialog">. An empty required field blocks it
Cancel, save draftformnovalidate, or a plain-submit Cancel is blocked too. close(), Esc and command="close" skip validation
ErrorsCancel invalid, write the message under the field (aria-describedby, aria-invalid), focus the first failing field
EnterPresses the first submit in the DOM: a left-hand dismiss is type="button"
Must stay openSteps, 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.

Stacked modals
RuleHow
OrderEsc closes the top one; focus returns to the button that opened it; the answer comes back as returnValue on close
Backdrops add upTwo at 0.55 leave about 0.80, and a blur blurs the blur: make the second lighter
Text that changes underneathSaid with its own ariaNotify() call, on that dialog
PopoversOne outside the modal’s subtree is inert while it is open: nest it inside, or close the modal first

Accessibility

Requirements

Requirements
WCAGRequirement
4.1.2 · 2.4.3showModal() 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.1Named with aria-labelledby (the heading); aria-label only without one. What happens and how to leave: aria-describedby
4.1.2No tabindex on the dialog. No role="dialog", aria-modal or aria-hidden on the page: native modality covers them
2.1.2Esc closes. closedby="none" owes labelled exits
1.4.10Scroll 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

Focus management
MomentRule
OpenPlaced 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
WhereBy 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
ReturnTo 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
TrapTab wraps inside the dialog: see Focus trap
ClosedOut 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.

Focus trap
SituationResult
Tab on the last controlGoes to the first control of the dialog, never to the browser interface
Shift+Tab on the first controlGoes to the last control
Ctrl/Cmd+L, F6Still reach the browser interface: no page can block them (whatwg/html#8339)
Focus inside video or audioThe wrap is off: their controls are the browser’s, and it would pin the user on the first control

Keyboard

Keyboard
KeyDoes
Tab, Shift+TabMoves inside the dialog and wraps: see Focus trap
EscCloses if closedby allows. Focus returns to the opener
EnterActivates 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.

Announcements
EventSaid onAnnouncedPriority
An attempt failsThe dialog"Attempt 2 of 3 failed."normal
The last attempt failsThe 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 doneThe progress bar"3 of 3 uploaded. You can close this dialog."normal
Retrying, each photo, openingNot saidThe text is on the page, or focus and the name say it—

WCAG criteria

WCAG criteria
Success criterionLevelMet by
1.3.1 Info and RelationshipsANamed by its heading (aria-labelledby), described by aria-describedby
1.3.2 Meaningful SequenceADOM order = visual order; actions in the order they are read
1.4.3 Contrast (Minimum)AAText in the panel 4.5:1 over the panel, not the backdrop
1.4.4 Resize TextAAPanel height from content; nothing clipped at 200%
1.4.10 ReflowAAFullscreen at 320 px; the panel scrolls inside, the page is locked
1.4.11 Non-text ContrastAAButtons, Close and the focus ring 3:1
2.1.1 KeyboardAEvery exit reachable by keyboard
2.1.2 No Keyboard TrapAEsc, or labelled exits when closedby="none"
2.1.4 Character Key ShortcutsAA one-key opener is switchable, remappable or scoped
2.3.3 Animation from InteractionsAAAprefers-reduced-motion removes translate and scale
2.4.3 Focus OrderAInitial focus placed on every open; focus returns to the opener from every exit
2.4.7 Focus VisibleAA3px ring on heading, buttons and media
2.4.11 Focus Not Obscured (Minimum)AANothing sits over the focused control inside the panel
2.5.8 Target Size (Minimum)AAClose and buttons 44 × 44
3.2.1 On FocusANever opened by focus, a timer or page load
3.3.1 Error IdentificationAForm errors under the field, inside the panel
3.3.4 Error Prevention (Legal, Financial, Data)AAConfirm destructive actions; the safe answer is focused
4.1.2 Name, Role, ValueANative dialog role and modality; alertdialog only when required
4.1.3 Status MessagesAAThe words are on the page as text and ariaNotify() says them from inside the panel

Related

References

The post is still in progress.