CSSModal & Dialog Patterns

Modal & Dialog Patterns

A modal interrupts the normal page flow to demand focused attention — a confirmation, a form, an image preview. Getting one right is mostly an accessibility problem: focus has to move into the modal, stay trapped there while it's open, and return to where it was when it closes. The native <dialog> element now handles most of that for free, which is why it's the recommended starting point over hand-rolled div-based modals.

The native <dialog> element

<dialog> is a built-in modal container. Opened with its .showModal() method, it automatically traps keyboard focus inside itself, blocks interaction with the rest of the page, closes on the Escape key, and exposes a special ::backdrop pseudo-element (see ::backdrop) you can style directly — all without a line of your own focus-management JavaScript.

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

<button id="open">Delete…</button>

<script>
  const dialog = document.getElementById('confirm');
  document.getElementById('open').addEventListener('click', () => {
    dialog.showModal();
  });
  dialog.addEventListener('close', () => {
    console.log('Result:', dialog.returnValue);
  });
</script>
  • showModal() opens it as a top-layer, focus-trapped modal; plain show() opens it as a non-modal panel (no focus trap, no backdrop, page stays interactive).

  • <form method="dialog"> submits by simply closing the dialog and setting returnValue to the clicked button's value — no JavaScript required for the button wiring.

  • Escape closes the dialog automatically and fires a cancel event, then a close event — listen for close to read the result.

  • The dialog is rendered in the top layer, a browser-managed layer above everything else in the document, which is why z-index never needs to fight it.

Styling the dialog and its backdrop

CSS
dialog {
  border: none;
  border-radius: 12px;
  padding: 1.5rem;
  width: min(90vw, 32rem);
  box-shadow: 0 20px 50px rgb(0 0 0 / 0.25);
}

/* Only paints while the dialog is open */
dialog::backdrop {
  background: rgb(15 23 42 / 0.55);
  backdrop-filter: blur(2px);
}
Note
::backdrop only exists for elements shown via showModal() (or fullscreen elements) — it never appears for a plain show() call, which is a useful mental hook for remembering which mode you are in.
Centering and open/close animation

A <dialog> is auto-centered by the UA stylesheet via inset auto-margins once it's in the top layer, so you rarely need centering CSS at all. Animating it in and out is the trickier part: an element removed from the top layer stops rendering immediately, mid-transition, unless you use @starting-style (see @starting-style) together with transition-behavior: allow-discrete.

CSS
dialog {
  opacity: 0;
  transform: scale(0.92) translateY(12px);
  transition:
    opacity 0.2s ease,
    transform 0.2s ease,
    overlay 0.2s ease allow-discrete,
    display 0.2s ease allow-discrete;
}

dialog[open] {
  opacity: 1;
  transform: none;
}

/* Entry state — the values to transition FROM when [open] is added */
@starting-style {
  dialog[open] {
    opacity: 0;
    transform: scale(0.92) translateY(12px);
  }
}

dialog::backdrop {
  background: rgb(15 23 42 / 0);
  transition: background 0.2s ease allow-discrete;
}

dialog[open]::backdrop {
  background: rgb(15 23 42 / 0.55);
}
  • transition-behavior: allow-discrete lets discrete properties (display, overlay) participate in a transition instead of flipping instantly.

  • @starting-style supplies the "before" values for the very first frame after the element enters the top layer — without it the browser has nothing to transition from and the fade-in never plays.

  • The same recipe closes gracefully: call dialog.close(), and because [open] is removed, the styles transition back before the element actually leaves the top layer.

Focus trapping and scroll locking

Focus trapping — Tab cycling only through elements inside the modal — is handled automatically by showModal() in every modern browser. What is not automatic is locking background scroll on some mobile browsers, and returning focus to the triggering element after close.

CSS
/* Prevent the page behind the modal from scrolling.
   Toggle a class on <html> or <body> when a modal opens. */
html:has(dialog[open]) {
  overflow: hidden;
}

HTML
<script>
  let lastFocused;
  function openModal(dialog) {
    lastFocused = document.activeElement;
    dialog.showModal();
  }
  document.querySelectorAll('dialog').forEach((dialog) => {
    dialog.addEventListener('close', () => {
      lastFocused?.focus();
    });
  });
</script>
Tip
The html:has(dialog[open]) selector needs no JavaScript class toggling at all in browsers that support :has() — the scroll lock turns on and off automatically as the dialog's open attribute is added and removed. See the :has() page for more.
Accessibility checklist
  • Give the dialog an accessible name: aria-labelledby pointing at its heading, or aria-label if there is no visible heading.

  • Use a real <h2>/<h3> for the title so screen readers announce it as a heading, not just text.

  • Never rely on <div role="dialog"> unless you truly cannot use <dialog> — you would be re-implementing focus trap, Escape handling, and top-layer stacking by hand.

  • Make sure the first focusable element (or the dialog itself via autofocus) receives focus when it opens — showModal() does this automatically for the first focusable descendant.

  • Confirm Escape closes the dialog (default browser behavior) unless you intentionally prevent it for a "you have unsaved changes" style guard, in which case provide a visible cancel affordance instead.

Warning
Don't cancel the default Escape behavior just to force users through your Cancel button — it breaks a keyboard convention every OS and browser has trained users to expect.
The popover attribute — lighter-weight overlays

For overlays that are not modal — dropdown menus, non-blocking notifications, disclosure panels — the popover attribute gives similar top-layer, light-dismiss behavior without demanding full modal focus trapping.

HTML
<button popovertarget="menu">Options</button>
<div id="menu" popover>
  <ul>
    <li><button>Rename</button></li>
    <li><button>Duplicate</button></li>
    <li><button>Delete</button></li>
  </ul>
</div>
  • popover (auto mode, the default) renders in the top layer and light-dismisses on outside click or Escape — perfect for menus and toast-style panels.

  • popover="manual" disables light-dismiss when you need to control opening/closing entirely yourself.

  • Style the open state with the :popover-open pseudo-class, and animate entry the same way as dialogs — @starting-style + allow-discrete transitions.

  • Popovers do not trap focus the way showModal() does — use <dialog> when the interaction must be fully blocking (forms, destructive confirmations).

Dialog vs popover vs hand-rolled div — when to use which

Need

Use

Blocks the rest of the page, traps focus, demands a decision

<dialog> + showModal()

Non-modal panel: menu, tooltip-like popup, toast

popover attribute

Inline panel that is part of normal page flow (no overlay)

Plain <div> with display toggling — no top layer needed

Full custom control over stacking, animation timing, nested modals

<dialog> (top layer) still beats manual z-index div stacks

Common mistakes
  • Using display: none / flex toggling on a plain <div> to fake a modal — you lose the top layer, ::backdrop, focus trap, and Escape handling, and have to hand-build all of it with JavaScript and ARIA.

  • Forgetting method="dialog" and then writing custom JavaScript just to close the dialog on every button click — the form handles it for free.

  • Animating display or the top-layer transition without allow-discrete and @starting-style — the dialog will simply pop in/out with no animation.

  • Not restoring focus to the trigger element after close, which strands keyboard and screen reader users.

Related pages: ::backdrop, @starting-style, :has(), and Tooltip patterns for lighter-weight overlays.