ESC

Type to search the knowledge base.

The dialog Element

Native modal and non-modal dialogs with showModal, light dismiss, focus, and the top layer — without a heavyweight widget library.

intermediate4 min read
  • html
  • dialog
  • modal
  • accessibility

The HTML <dialog> element is the platform’s answer to “please build an accessible modal.” It gives you open/close semantics, a top layer, focus handling hooks, and form method dialog — without inventing role="dialog" from scratch (though you still must design content carefully).

Docs: MDN <dialog>, HTML living standard — dialog, Open UI / modal patterns.

<dialog id="confirm">
  <form method="dialog">
    <p>Delete this project? This cannot be undone.</p>
    <menu>
      <button value="cancel" type="submit">Cancel</button>
      <button value="confirm" type="submit">Delete</button>
    </menu>
  </form>
</dialog>

<button type="button" id="open">Delete project</button>
const dialog = document.querySelector('#confirm');
document.querySelector('#open').addEventListener('click', () => {
  dialog.showModal(); // modal: top layer + backdrop + inert-ish page
});

dialog.addEventListener('close', () => {
  console.log(dialog.returnValue); // "cancel" | "confirm" | ""
});
API Behavior
showModal() Modal dialog on the top layer, with ::backdrop, traps focus in the dialog (browser-managed), rest of page non-interactive
show() Non-modal dialog; page remains interactive; no top-layer modal behavior
close(returnValue?) Closes; sets returnValue
open property / attribute Reflects open state (prefer APIs for modal)

Use modal for confirmations, blocking forms, and anything that must capture the task. Use non-modal sparingly (e.g. persistent inspector); many “non-modal dialogs” are better as inline disclosure or popovers.

Why not a centered div?

Hand-rolled modals routinely miss:

  1. Top layer — paints above z-index wars without stacking-context archaeology (position / stacking).
  2. Focus — move focus in on open, restore on close; Tab cycles inside.
  3. Escape to close (modal dialogs).
  4. Backdrop click policies.
  5. inert background content so AT doesn’t walk the page under the mask.

<dialog> covers a large chunk of that matrix. You still supply titles, labels, and clear actions.

Accessible naming

<dialog id="edit" aria-labelledby="edit-title" aria-describedby="edit-desc">
  <h2 id="edit-title">Edit profile</h2>
  <p id="edit-desc">Changes apply to your public profile.</p>
  <!-- fields -->
  <button type="button" id="edit-close">Close</button>
</dialog>
  • Give the dialog an accessible name (aria-labelledby to a visible heading is ideal).
  • Put the initial focus on a sensible control (first field or primary action) — browsers focus the dialog; refine if needed.
  • Ensure a clear way to dismiss (button + Escape for modals).

Forms and method="dialog"

<dialog id="nickname">
  <form method="dialog">
    <label for="nick">Nickname</label>
    <input id="nick" name="nick" />
    <button value="save">Save</button>
  </form>
</dialog>

Submitting a form with method="dialog" closes the dialog and sets returnValue from the submitter’s value. The form is not navigated as a normal HTTP submit. Read field values in the close handler or before close if you need them — once closed, design your state flow explicitly in SPA frameworks.

Light dismiss (backdrop)

Backdrop clicks do not always close by default in all cases you might expect; check current browser behavior and implement deliberately:

dialog.addEventListener('click', (e) => {
  const rect = dialog.getBoundingClientRect();
  const inDialog =
    rect.top <= e.clientY &&
    e.clientY <= rect.bottom &&
    rect.left <= e.clientX &&
    e.clientX <= rect.right;
  if (!inDialog) dialog.close('cancel');
});

(Or compare e.target === dialog depending on structure.) For destructive flows, prefer explicit Cancel over accidental backdrop dismiss.

Styling

dialog {
  border: 1px solid var(--border);
  border-radius: 12px;
  padding: 1.25rem;
  max-width: min(28rem, 100vw - 2rem);
}
dialog::backdrop {
  background: rgb(0 0 0 / 0.45);
}
dialog:not([open]) {
  display: none; /* default; don’t fight it carelessly */
}

Avoid display: none hacks that break the open state. Animate with caution — respect prefers-reduced-motion. Fixed centering used to need absolute hacks; dialogs are centered by UA styles you can override.

Framework notes (React / Next)

  • Control open state carefully: call showModal() in an effect when open becomes true; call close() when false. Don’t only toggle an open attribute if you need modal top-layer behavior.
  • Portals are less necessary for stacking because of the top layer, but app structure may still wrap dialogs at root for state.
  • Sync with route segments (intercepting routes, parallel routes) if the URL should represent the modal — App Router pattern territory (App Router overview).

Common mistakes

  1. Using show() when you needed showModal().
  2. No accessible name / heading.
  3. Nested modals without a clear stack story.
  4. Forgetting to restore focus to the invoker on close (test if UA + your focus moves are enough).
  5. Trapping scroll on body manually in ways that fight the browser.
  6. Building “modals” for simple messages that should be inline errors or non-modal announcements.

Interview angle

Contrast showModal vs show. Mention top layer, Escape, and form method="dialog" / returnValue. Explain why native dialog beats a div + z-index: 9999. Tie to focus management and naming.

Live coding: confirmation modal with Cancel/Delete, keyboard Escape, and returnValue handling.

Further reading

Related guides