Browse by section

Web Design 日本語

The HTML dialog Element: How to Use It Properly

For modals, use the <dialog> element rather than a hand-rolled div. The browser handles focus trapping, making the background inert, dismissing with Escape, and styling the backdrop through ::backdrop.

<dialog> became usable in every modern browser in March 2022 and is now Baseline (widely available). Since then, two additions have closed the remaining gaps: the closedby attribute for controlling how a dialog can be dismissed, and @starting-style for animating it.

This article starts from the difference between show() and showModal() and goes through the parts that actually cause trouble: closing on outside click, animating the close, and reading back a result.

Sponsored

show() vs showModal()

In almost every situation the one you want is showModal(). With show(), the browser protects nothing for you.

showModal() show()
Background interaction Blocked (effectively inert) Allowed
Focus trapping Yes No
Escape closes it Yes (cancel event) No
::backdrop Applies Does not apply
ARIA aria-modal="true" aria-modal="false"
Stacking Top layer (ignores z-index) Normal flow

show() is only right for a panel you want to consult while continuing to use the page—a filter panel, a chat window. In that case you have to implement Escape handling yourself.

A non-modal dialog

See the Pen
dialog01
by Rin (@rinblog0408)
on CodePen.

<button id="open-dialog">Open non-modal dialog</button>

<dialog id="non-modal-dialog">
  <p>This is non-modal. You can still use the page behind it.</p>
  <button id="close-dialog" autofocus>Close</button>
</dialog>

<script>
  const dialog = document.getElementById('non-modal-dialog');
  const openButton = document.getElementById('open-dialog');
  const closeButton = document.getElementById('close-dialog');

  openButton.addEventListener('click', () => {
    dialog.show(); // non-modal
  });

  closeButton.addEventListener('click', () => {
    dialog.close();
  });

  // non-modal dialogs do not close on Escape — do it yourself
  document.addEventListener('keydown', (e) => {
    if (e.key === 'Escape' && dialog.open) dialog.close();
  });
</script>

That last Escape handler is mandatory for non-modal dialogs. Without it, keyboard-only users have no way to dismiss the dialog.

Sponsored

A modal dialog

See the Pen
dialog02
by Rin (@rinblog0408)
on CodePen.

<button id="open-modal-dialog">Open modal dialog</button>

<dialog id="modal-dialog">
  <h2>Confirm</h2>
  <p>This is modal. The page behind it cannot be used until you close it.</p>
  <button id="close-modal-dialog" autofocus>Close</button>
</dialog>

<script>
  const modalDialog = document.getElementById('modal-dialog');

  document.getElementById('open-modal-dialog')
    .addEventListener('click', () => modalDialog.showModal());

  document.getElementById('close-modal-dialog')
    .addEventListener('click', () => modalDialog.close());
</script>

Note the autofocus. Without it, focus lands on the first focusable element in the dialog. If a destructive button such as “Delete” comes first, always put autofocus on the safe option instead—otherwise repeated Enter presses cause accidents.

Do not put tabindex on the <dialog> itself; the container is not an interactive element.

Closing on outside click: the closedby attribute

“Close when the user clicks outside” is the most requested behaviour, and it does not happen by default. One attribute enables it: closedby.

<dialog id="dlg" closedby="any">
  <p>Closes on outside click and on Escape</p>
  <button autofocus>Close</button>
</dialog>
Value How it can be closed
none Only your buttons or close(). Escape does nothing
closerequest Escape plus your own mechanisms (the default for showModal())
any Light dismiss (outside click) plus Escape plus your own

As of September 2026, closedby is supported in Chrome, Edge, and Firefox, but not Safari (it is an Interop 2026 focus area). For now, ship a fallback alongside it.

const dlg = document.getElementById('dlg');

// fallback for browsers without closedby
if (!('closedBy' in dlg)) {
  dlg.addEventListener('click', (e) => {
    // a click on the dialog itself means the backdrop area
    if (e.target === dlg) dlg.close();
  });
}

This works because clicks outside the dialog’s box (the backdrop region) arrive as events on the <dialog> element itself. Wrap the contents in a single <div> so that inner clicks cannot accidentally close it.

Conversely, for a form with unsaved input you can set closedby="none" so nothing but an explicit Cancel button closes it.

Sponsored

Reading a result back with method=”dialog”

Give a form inside a <dialog> the attribute method="dialog" and submitting closes the dialog without sending anything, putting the pressed button’s value into returnValue. Confirmation dialogs become very short.

<dialog id="confirm-dialog">
  <form method="dialog">
    <p>Delete this post?</p>
    <button value="cancel" autofocus>Cancel</button>
    <button value="delete">Delete</button>
  </form>
</dialog>
const confirmDialog = document.getElementById('confirm-dialog');

confirmDialog.addEventListener('close', () => {
  if (confirmDialog.returnValue === 'delete') {
    deletePost();
  }
});

confirmDialog.showModal();

There are two events, and mixing them up leads to handlers that never run:

  • close: fires whenever the dialog closes. Read returnValue here
  • cancel: fires when a modal is dismissed with Escape. returnValue is not updated

After an Escape dismissal, returnValue is still an empty string. Writing the handler as “only act on a specific value”, as above, makes Escape fail safe automatically.

Animating open and close

<dialog> moves between display: none and display: block, so a plain transition does nothing. You need @starting-style and transition-behavior: allow-discrete.

dialog {
  opacity: 0;
  transform: translateY(-16px);
  transition:
    opacity .3s ease-out,
    transform .3s ease-out,
    overlay .3s ease-out allow-discrete,
    display .3s ease-out allow-discrete;
}

dialog:open {
  opacity: 1;
  transform: translateY(0);
}

@starting-style {
  dialog:open {
    opacity: 0;
    transform: translateY(-16px);
  }
}

dialog::backdrop {
  background: rgb(0 0 0 / 0);
  transition: background .3s ease-out, overlay .3s ease-out allow-discrete, display .3s ease-out allow-discrete;
}
dialog:open::backdrop {
  background: rgb(0 0 0 / .5);
}
@starting-style {
  dialog:open::backdrop { background: rgb(0 0 0 / 0); }
}

@media (prefers-reduced-motion: reduce) {
  dialog, dialog::backdrop { transition: none; }
}

Three things to get right:

  • Include display and overlay in the transition with allow-discrete, or the close animation is skipped entirely
  • Declare the starting values in @starting-style, or the open animation never plays
  • Always provide a prefers-reduced-motion branch

Scrolling and sizing issues

While a modal is open, the page behind it can still scroll. showModal() blocks interaction, not scrolling.

body:has(dialog[open]) {
  overflow: hidden;
}

For long dialogs, cap the height on the dialog itself:

dialog {
  max-width: min(90vw, 640px);
  max-height: 85dvh;
  overflow: auto;
  border: none;
  border-radius: 12px;
  padding: 24px;
}

Using dvh avoids the bottom of the dialog being cut off as mobile address bars expand and collapse.

Summary

  • Use showModal(). show() gives you no focus trap, no Escape, no ::backdrop
  • Put autofocus on the safe button. Never tabindex on <dialog>
  • For outside-click dismissal use closedby="any", with an e.target === dialog fallback for Safari
  • Read results with <form method="dialog"> and returnValue; know the difference between close and cancel
  • Animate with @starting-style plus transition-behavior: allow-discrete, including display and overlay
  • Stop background scrolling with body:has(dialog[open]) { overflow: hidden; }

Focus management and Escape handling are the hardest parts of a hand-built modal, and <dialog> takes both off your hands. For comparison, I previously wrote about building a modal window with jQuery—useful for seeing how much the element removes.