</> HTML5Advent
ENFRESDEITPT

// html · Web Platform Advent #18

The HTML dialog element: real modals without a library

The HTML dialog element gives you a true modal - top layer, inert page, backdrop and Esc handling - from plain markup. Here is showModal vs show, form method=dialog, returnValue, the closedby attribute and the one habit to avoid.

A code editor in dark mode showing an index.html file with the doctype and head tags

Every front-end developer has built a modal by hand: a fixed-position div, a z-index war, a click-outside listener, an Esc handler, and some focus juggling so the keyboard does not wander off behind the overlay. The <dialog> element replaces all of it with one tag and one method call. This is what it actually does, and the two or three details that decide whether your dialog behaves properly.

The smallest working dialog

A dialog is inert markup until you open it from JavaScript:

<dialog id="confirm">
  <p>Delete this file?</p>
  <button autofocus>Cancel</button>
</dialog>

<button id="open">Delete</button>
const dialog = document.getElementById("confirm");
document.getElementById("open")
  .addEventListener("click", () => dialog.showModal());

That single showModal() call is doing considerably more than making an element visible.

showModal() versus show(): the difference that matters

These two methods are not variations on a theme. They produce genuinely different things.

showModal() opens a modal dialog. The browser puts it in the top layer, so it renders above the entire page with no z-index involved. It renders a ::backdrop pseudo-element behind it. It makes everything else inert, which means the rest of the document cannot be clicked or tabbed into. And it implicitly sets aria-modal="true".

show() opens a non-modal dialog. The page stays interactive, there is no top layer, no backdrop, and aria-modal is false.

The inertness is the part people underestimate. Making the background unclickable is easy; making it untabbable is the part hand-rolled modals almost always get wrong, and here the browser does it for you.

The habit to drop: the open attribute

A <dialog> also has a boolean open attribute, and reaching for it is the single most common mistake with this element.

<!-- works, but this is NOT a modal -->
<dialog open>I am non-modal, whatever you expected.</dialog>

A dialog shown through the open attribute is always non-modal. No top layer, no backdrop, no inert page. MDN recommends using show() or showModal() rather than the attribute, and describes toggling open by hand as not the recommended practice. If your modal looks right but the page behind it still responds to clicks, this is almost certainly why.

A hand-drawn wireframe on paper, with crossed rectangles standing in for images and ruled lines for body text
A hand-drawn interface wireframe, with crossed rectangles standing in for images and ruled lines for text. Confirmation dialogs are usually sketched at this stage and rebuilt from scratch in every project.

form method="dialog": the feature with no equivalent

This is the part of <dialog> that nothing else replicates, and it is the reason a confirmation dialog can need no JavaScript beyond opening it.

<dialog id="confirm">
  <form method="dialog">
    <p>Delete this file?</p>
    <button value="cancel" autofocus>Cancel</button>
    <button value="delete">Delete</button>
  </form>
</dialog>

When a form inside a dialog is submitted with method="dialog", the dialog closes instead of sending a request. The states of the form controls are saved but not submitted, and the dialog's returnValue is set to the value of the button that was activated.

So you read the answer afterwards:

dialog.addEventListener("close", () => {
  if (dialog.returnValue === "delete") {
    // the user confirmed
  }
});

Two buttons, one attribute, and you know which one was pressed. No click handlers, no promise wrapper, no state variable.

The required-field trap

One behaviour surprises people the first time and is worth knowing before it costs you an afternoon.

If the dialog contains a form with required inputs, submitting still runs validation. A plain button cannot close the dialog while those fields are invalid, which means your Cancel button stops working precisely when the user wants it most. The documented answers are to add formnovalidate to the dismissing button, or to call dialog.close() from your own code:

<button value="cancel" formnovalidate>Cancel</button>

Esc, and the closedby attribute

Keyboard dismissal is not uniform, and the difference follows the modal split.

A dialog opened with showModal() can be dismissed with Esc by default. A non-modal dialog does not dismiss with Esc by default.

The closedby attribute makes this explicit rather than implied:

<dialog closedby="any">     <!-- Esc, outside click, or your code -->
<dialog closedby="closerequest">  <!-- Esc or your code -->
<dialog closedby="none">    <!-- only your code -->

closedby="any" is what gives you light-dismiss, the outside-click behaviour people usually write by hand. closedby="none" is for the rare dialog that must not be dismissed accidentally, and it comes with an obligation: you have to provide a visible way out.

Getting the accessibility right

The element hands you most of this, but three points are on you.

Use autofocus deliberately. With showModal(), focus lands on the first nested focusable element. That is often not the one you want. Put autofocus on the element the user should interact with immediately, or on the close button if nothing else is more urgent.

Always provide a close button. The most robust way to ensure every user can leave the dialog is an explicit button, not only Esc or an outside click.

Do not put tabindex on the dialog itself. It is not interactive and does not receive focus. Its contents do.

Styling the backdrop

The dimmed layer behind a modal is a real pseudo-element you can style:

dialog::backdrop {
  background: rgb(0 0 0 / 0.5);
  backdrop-filter: blur(2px);
}

It only exists for dialogs opened with showModal(), which is another way to check at a glance whether you are actually in modal mode.

dialog or popover?

Both render in the top layer, so the question comes up constantly. The distinction is about interruption.

Use <dialog> with showModal() when the user must deal with something before continuing: a confirmation, a required choice, a form that blocks the flow. Blocking the page is the entire point.

Use the Popover API for transient overlay UI that must not interrupt: menus, tooltips, cards, notifications. There the page should stay usable.

A useful test: if it would be wrong for the user to ignore it and carry on, it is a dialog. If ignoring it is a perfectly reasonable response, it is a popover.

The bottom line

The <dialog> element turns a genuinely hard piece of UI into markup. Open it with showModal() and you get the top layer, a backdrop, an inert page and Esc handling without writing any of it. Use form method="dialog" and returnValue and you get the user's answer without a single click handler. Avoid the open attribute, remember formnovalidate on your cancel button, and place autofocus yourself.

What remains is a confirmation dialog in about fifteen lines of HTML that behaves correctly for keyboard and screen reader users, which is more than most of the hand-built ones it replaces.

Frequently asked questions

What is the difference between show() and showModal()?
showModal() opens a modal dialog: the browser puts it in the top layer, renders a ::backdrop behind it, makes the rest of the page inert so nothing outside can be clicked or tabbed to, and implicitly sets aria-modal="true". show() opens a non-modal dialog: the rest of the page stays interactive, there is no top layer and no backdrop, and aria-modal is false. If you want a real modal, showModal() is the one.
Should I open a dialog with the open attribute?
No. MDN states plainly that it is recommended to use the show() or showModal() method rather than the open attribute. A dialog shown through the open attribute is always non-modal, so you get none of the top layer, backdrop or inertness you probably wanted. Toggling the attribute by hand is explicitly described as not the recommended practice.
What does form method="dialog" do?
A form inside a dialog with method="dialog" closes the dialog when submitted instead of sending a request. The states of the form controls are saved but not submitted, and the dialog's returnValue property is set to the value of the button that was activated. That gives you which button the user pressed with no JavaScript at all.
Does the Esc key close a dialog?
For a modal opened with showModal(), yes: it can be dismissed with Esc by default. For a non-modal dialog, no, Esc does not dismiss it by default. The closedby attribute lets you change this: closerequest allows Esc, any also allows an outside click, and none means only your own code can close it.
Why will my dialog not close when the form has a required field?
Because the form still runs validation. If the dialog contains required inputs, a plain submit button cannot close it while those fields are invalid. Add formnovalidate to the button meant to dismiss the dialog, or call dialog.close() from your own code instead.