The dialog Element: Native Modals

Modals with focus trapping, backdrop, Escape handling and inertness supplied by the browser. A component that used to need a library is now one element.

Concept

A modal dialog is one of the hardest components to build correctly. Done by hand it needs focus trapping, an inert background, Escape handling, a backdrop, scroll locking, focus restoration and correct ARIA roles - and most hand built versions get at least two of those wrong.

The dialog element supplies all of it.

Syntax

<button type="button" id="open">Apply now</button>

<dialog id="apply-dialog">
  <h2>Apply for the design course</h2>
  <p>Applications close on 30 June.</p>

  <form method="dialog">
    <button type="submit" value="cancel">Close</button>
    <button type="submit" value="continue">Continue</button>
  </form>
</dialog>
const dialog = document.getElementById("apply-dialog");

document.getElementById("open").addEventListener("click", () => {
  dialog.showModal();
});

dialog.addEventListener("close", () => {
  console.log(dialog.returnValue);   // "cancel" or "continue"
});

showModal versus show

showModal()show()
Rest of the pageInert - not clickable, not focusable, not readFully usable
BackdropYes, ::backdropNo
Escape closes itYesNo
Focus trapped insideYesNo
LayerThe browser top layer, above everythingNormal stacking

showModal() is what modal means and is what you want almost always. show() creates a non modal dialog, closer to a floating panel.

Do not use the open attribute to display a modal. It shows the dialog without any of the modal behaviour - no backdrop, no focus management, no inertness.

What the browser handles for you

  • Focus moves in on open, to the first focusable element or to the element carrying autofocus.
  • Focus is trapped: Tab cycles within the dialog and cannot escape.
  • The background is inert: nothing behind it can be clicked, focused or read by a screen reader.
  • Escape closes it, firing a cancel event first.
  • Focus is restored to the element that opened it.
  • It renders in the top layer, above every stacking context, so no z-index fight is possible.

That list is the entire reason to use this element.

method="dialog"

A form inside a dialog with method="dialog" closes the dialog on submit instead of sending anything to a server, and sets returnValue to the pressed button value.

<dialog id="confirm">
  <h2>Delete this application?</h2>
  <p>This cannot be undone.</p>
  <form method="dialog">
    <button type="submit" value="no" autofocus>Keep it</button>
    <button type="submit" value="yes">Delete</button>
  </form>
</dialog>
confirm.addEventListener("close", () => {
  if (confirm.returnValue === "yes") deleteApplication();
});

Note the autofocus on the safe option. For a destructive confirmation, focus should land on the action that does nothing.

Styling

dialog {
  border: none;
  border-radius: 12px;
  padding: 1.5rem;
  max-width: 32rem;
  width: calc(100% - 2rem);
  box-shadow: 0 20px 60px rgba(15, 23, 42, 0.25);
}

dialog::backdrop {
  background: rgba(15, 23, 42, 0.5);
  backdrop-filter: blur(2px);
}

/* a closed dialog is display:none, so animate carefully */
dialog[open] {
  animation: dialog-in 160ms ease-out;
}

@keyframes dialog-in {
  from { opacity: 0; transform: translateY(8px); }
  to   { opacity: 1; transform: none; }
}

@media (prefers-reduced-motion: reduce) {
  dialog[open] { animation: none; }
}

::backdrop is a real pseudo element and cannot be reached any other way. It only exists for a dialog opened with showModal().

Closing on a backdrop click

Not built in, and worth adding. The trick is that a click on the backdrop reports the dialog itself as the target, because the backdrop belongs to it:

dialog.addEventListener("click", (event) => {
  if (event.target === dialog) dialog.close();
});

For that to work, the dialog needs an inner wrapper holding the content, so clicks on the content do not match.

Accessibility notes

  • The element already has role="dialog". Do not add it.
  • Give it an accessible name: a heading plus aria-labelledby, or an aria-label.
  • Always provide a visible close control. Escape alone is not enough on touch.
  • Put autofocus on the least destructive control.
  • Do not open a modal on page load. It is disorienting and usually dismissed.
<dialog id="apply" aria-labelledby="apply-title">
  <h2 id="apply-title">Apply for the design course</h2>
  <button type="button" class="close" aria-label="Close dialog"></button>
</dialog>

Important rules

  • A closed dialog is display: none, so its contents are unreachable.
  • showModal() throws if the dialog is already open.
  • close() can be given a value that becomes returnValue.
  • The cancel event fires on Escape and can be prevented.
  • Body scroll is not locked automatically; add overflow: hidden on html if the background scrolling bothers you.
  • Only one modal dialog can be in the top layer at a time in practice.

Common mistakes

  • Using the open attribute and losing every modal behaviour.
  • Adding role="dialog" redundantly.
  • No accessible name, so it is announced as just dialog.
  • No visible close button.
  • Focusing the destructive option by default.
  • Trying to animate a dialog without accounting for display: none.
  • Building a modal from a div and reimplementing all of this by hand.

Best practices

  • Always showModal() for a modal.
  • Name the dialog with aria-labelledby pointing at its heading.
  • Use method="dialog" for confirmation forms.
  • Add backdrop click closing where it is appropriate.
  • Respect prefers-reduced-motion.
  • Never open a modal unprompted.
  • Consider the popover attribute for lightweight non modal overlays.

Practice

  1. Build a confirmation dialog with two buttons and read returnValue on close.
  2. Open it with the open attribute and then with showModal(), and list every difference in behaviour.
  3. Style ::backdrop with a blur and confirm it only appears for the modal form.
  4. Tab through an open dialog and confirm focus cannot leave it, then confirm focus returns to the trigger on close.

Useful resources

Hand picked references for this topic
Written by Lorens Mishra

Default administrator account created by the installer.

Continue reading

All HTML notes →
HTML

The canvas Element

A blank bitmap you draw on with JavaScript. Powerful for graphics and games, and invisible to every reader who cannot see the screen.

Read more
HTML

The template Element

Markup that is parsed but not rendered, waiting to be cloned. The clean way to build repeated content in JavaScript without writing HTML as strings.

Read more
HTML

What HTML5 Actually Means

HTML5 is not a version you upgrade to. It is the name for a shift from a frozen specification to a living standard, and for the elements and APIs that...

Read more

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.