Skip to content
Contact me

All building blocks

Sandbox

The modal dialog

Without focus management, people who use the keyboard end up behind the modal dialog, in a page they can no longer see. The dialog element handles that focus on its own, and since December 2025 a button can open it with no script in every recent browser.

Built with
dialog and command
Browsers
Chrome 135+, Firefox 144+, Safari 26.2+
JavaScript
None, 5 lines as a fallback

Demo

Delete this draft?

The draft will be deleted for good.

Anatomy

  1. button command="show-modal" The button that opens the dialog, tied to it by commandfor.
  2. dialog The dialog, placed above the whole page.
  3. h2 autofocus The heading, which names the dialog and gets focus when it opens.
  4. Content The sentence that says what deleting leads to.
  5. form method="dialog" The Cancel and Delete buttons, which close the dialog with no script. The value of the chosen button goes into returnValue.
  6. ::backdrop The backdrop laid over the page.

HTML code

HTML
<!-- Trigger -->
<button
  type="button"
  commandfor="delete-dialog"
  command="show-modal">
  Delete the draft
</button>

<!-- Modal dialog -->
<dialog id="delete-dialog" aria-labelledby="delete-dialog-title">
  <!-- Heading: names the dialog, and autofocus puts focus on it when it opens -->
  <h2
    id="delete-dialog-title"
    tabindex="-1"
    autofocus>
    Delete this draft?
  </h2>
  <p>The draft will be deleted for good.</p>
  <!-- Both buttons close the dialog, then focus goes back to the trigger.
       dialog.returnValue gets the value of the chosen button -->
  <form method="dialog">
    <button value="cancel">Cancel</button>
    <button value="delete">Delete</button>
  </form>
</dialog>
CSS
dialog::backdrop {
  background: rgb(0 86 59 / 0.45);
}

/* The page no longer scrolls behind the open dialog.
   scrollbar-gutter keeps room for the scrollbar: the page does not shift */
html:has(dialog:modal) {
  overflow: hidden;
  scrollbar-gutter: stable;
}

Usage

Use it for

  • A short action that needs an answer before going on, such as confirming a deletion.
  • Content that only makes sense at the moment you click the button.

Avoid it for

  • Letting the page scroll behind the dialog. The mouse wheel or a finger moves inert content, and you lose your place when it closes: html:has(dialog:modal) blocks scrolling, with no script.
  • A dialog that opens on its own when the page loads.
  • A Close button shaped like a cross with no accessible name.
  • <div role="dialog"> instead of dialog: you then have to handle focus and Escape by hand.
  • Long content that people would want to read while keeping the page in view.

Accessibility

ARIA roles and attributes

aria-labelledby is the only ARIA attribute in the demo. Opened in modal mode, the dialog does without aria-modal.

AttributeElementDescription
commandfor="delete-dialog"buttonPoints to the dialog to open through its id. The button keeps a name that says what it triggers: "Delete the draft".
command="show-modal"buttonOpens the dialog in modal mode, with no script. When it closes, focus goes back to this button.
dialog role (implicit)dialogThe screen reader gets a dialog, modal since show-modal opened it.
aria-labelledby="delete-dialog-title"dialogGives the dialog the name of its heading: when it opens, the screen reader announces "Delete this draft?, dialog".
id="delete-dialog-title"h2The target of aria-labelledby. If the heading text changes, the dialog name follows.
tabindex="-1"h2Lets the heading get focus, without putting it in the tab order.
autofocush2Puts focus on the heading when the dialog opens: the screen reader goes on with "Delete this draft?, heading level 2".
showModal()Rest of the pageModal mode, started by command="show-modal" or by the fallback, makes the page inert while the dialog is open: not read, not clickable, not reachable with the keyboard.

Keyboard navigation

KeyAction
Enter or Space on the buttonOpens the dialog. Focus lands on the heading
Tab and Shift+TabMove through the Cancel and Delete buttons. Focus can go through the browser address bar, never through the page behind
EscapeCloses the dialog. Focus goes back to the button that opened it

Tab order

  1. The "Delete the draft" button, on the page.
  2. The dialog heading, when it opens. It gets focus only once, Tab does not come back to it.
  3. The "Cancel" button.
  4. The "Delete" button. Tab then goes back to "Cancel", sometimes after the browser address bar.

With a screen reader

Example in Chrome with VoiceOver in English. The wording changes from one screen reader to another.

ActionAnnouncement
Enter on the button"Delete this draft?, dialog", then "Delete this draft?, heading level 2"
Tab"Cancel, button"
Escape"Delete the draft, button"

Related RGAA criteria

The RGAA is the French accessibility standard. Its 106 criteria test WCAG 2.1 level AA.

Criterion 7.1: Is each script, where needed, compatible with assistive technologies?
The JavaScript fallback only calls showModal(): the role, the name and the modal state stay those of dialog.
Criterion 10.7: On each web page, for each element that receives focus, is the focus visible?
Focus stays visible on the heading and on both buttons.
Criterion 12.8: On each web page, is the tab order consistent?
Tab only goes through the Cancel and Delete buttons, never the page behind. When the dialog closes, focus resumes on the button that opened it.
Criterion 12.9: On each web page, navigation must not contain any keyboard trap. Is this rule met?
No keyboard trap: Escape and the Cancel button always let you out.

Other references

Beyond the APG

New featureWhat it addsBrowsersIn the demo
command and commandforOpen and close the dialog from a button, with no script.Chrome 135+, Firefox 144+, Safari 26.2+Yes
closedbyCloses the dialog with a click on the backdrop, with no script.Chrome 134+, Firefox 141+Variation

Variations

Close on backdrop click

The closedby="any" attribute also closes the dialog on a click outside it. It does not exist in every browser yet: keep the Cancel button.

Delete this draft?

The draft will be deleted for good.

A click on the backdrop also closes the dialog.

Code
<dialog
  id="delete-dialog"
  closedby="any"
  aria-labelledby="delete-dialog-title">

Fallback for older browsers

Before December 2025, not every browser supported command (Safari added it last, in version 26.2). A short script takes over: on a click on the button, dialog.showModal() opens the dialog.

The demo above already includes this fallback: try it in a browser from before December 2025.

Code
<script>
  // Fallback: browsers from before December 2025, which do not support command
  if (!('commandForElement' in HTMLButtonElement.prototype)) {
    const trigger = document.querySelector('[commandfor="delete-dialog"]');
    const dialog = document.getElementById('delete-dialog');
    trigger.addEventListener('click', () => dialog.showModal());
  }
</script>