10LOC
Chrome APIsintermediate

<dialog> for an accessible modal with zero JS libraries

Published October 9, 2026

<button id="delete-trigger">Delete project</button>

<dialog id="confirm-dialog" closedby="any" aria-labelledby="confirm-title">
  <form method="dialog">
    <h2 id="confirm-title">Delete this project?</h2>
    <p>This can't be undone.</p>
    <menu>
      <button value="cancel">Cancel</button>
      <button value="confirm" autofocus>Delete</button>
    </menu>
  </form>
</dialog>

<script>
  const dialog = document.getElementById("confirm-dialog");
  document.getElementById("delete-trigger").addEventListener("click", () => dialog.showModal());
  dialog.addEventListener("close", () => {
    if (dialog.returnValue === "confirm") deleteProject();
  });
</script>

What

<dialog> opened with showModal() gets focus-trapping, Esc-to-close, an automatically-inert background, and a ::backdrop pseudo-element to style — the full modal behavior a focus-trap library exists to provide, natively.

Why it matters

A hand-rolled modal has a checklist: trap Tab/Shift-Tab inside it, close on Esc, close (or don't) on backdrop click, make the rest of the page unreachable by screen reader and keyboard, restore focus to the trigger on close, render above everything else regardless of ancestor overflow/z-index. react-modal, focus-trap, and friends exist because getting all of that right by hand is fiddly and easy to get subtly wrong.

showModal() does the whole checklist. The rest of the document becomes inert automatically — not just visually, but for tab order, screen readers, and Element.focus() calls — for as long as the dialog is open. No manual inert toggling, no manual Tab-key interception.

How it works

The snippet's <dialog> uses a <form method="dialog"> so the two buttons close it and set returnValue without any JS at all — submitting a method="dialog" form closes the nearest ancestor dialog and sets its returnValue to the submitting button's value.

  • closedby="any" is the one non-default piece here. Without it, a showModal() dialog only closes via Esc or your own JS/form — clicking the ::backdrop does nothing by default. closedby="any" additionally allows light-dismiss (click outside to close), matching the light-dismiss behavior of popover="auto".
  • autofocus on the confirm button sets initial focus without JS — showModal() focuses the first autofocus element it finds, or the dialog itself if there isn't one.
  • The close event (added via a tiny script, not shown as markup) fires after the dialog closes either way, and dialog.returnValue tells you which button closed it.

Gotchas

  • closedby default depends on how you opened the dialog. showModal() defaults to "closerequest" (Esc + your own JS, no backdrop click). show() or the plain open attribute (non-modal) defaults to "none". If you want backdrop-click-to-dismiss on a modal, you have to opt in with closedby="any" explicitly.
  • Never put tabindex on the <dialog> element itself — it isn't meant to be a focus target; its contents are.
  • A declarative alternative to a showModal() JS call is emerging: <button commandfor="confirm-dialog" command="show-modal">, part of the newer HTML Invoker Commands API. It's shipped across Chrome, Edge, Firefox, and Safari only since around December 2025 — recent enough that a JS .showModal() call is still the safer default if you need to support anything older.
  • <dialog> itself, showModal(), and ::backdrop have been broadly supported since 2022; closedby is the newer addition here (added across all four major engines through 2025).

Related

  • The Popover API — top-layer rendering and light-dismiss like a dialog, but without the automatic background-inert behavior; use it for menus/tooltips, not for anything that should block the rest of the page.
  • The inert attribute — what showModal() applies to the rest of the page for you; reach for it directly when building a modal-like UI that isn't an actual <dialog>.