The `<dialog>` element replaces div overlays

Free preview 4 min to read

Before HTML added <dialog>, every modal on the web was a <div> with position: fixed, a backdrop built from another <div>, and a pile of JavaScript to manage focus, keyboard events and screen reader announcements. That approach works, but it reimplements browser behavior that the <dialog> element provides natively.

Two modes: non-modal and modal

A <dialog> element has two opening modes. The first, dialog.show(), opens it as a non-modal panel: it appears on screen, but the rest of the page stays interactive. The second, dialog.showModal(), opens it as a modal dialog. The browser disables interaction with everything behind it, adds a ::backdrop pseudo-element and keeps keyboard focus inside the dialog.

// Non-modal: page stays interactive
dialog.show()

// Modal: page becomes inert, backdrop appears, focus trapped
dialog.showModal()

This app uses showModal() everywhere. The welcome modal, the discussion thread and the command palette all need to block the page behind them.

What the browser gives you for free

When you call showModal(), the browser does five things that a <div> overlay can't do without JavaScript:

flowchart LR
  A["showModal()"] --> B["Adds to top layer"]
  A --> C["Renders ::backdrop"]
  A --> D["Traps Tab focus"]
  A --> E["Escape closes"]
  A --> F["Marks page inert"]
  1. Top layer: the dialog moves to the browser's top layer, above every other element and above any z-index value, with no stacking-context tricks.
  2. Backdrop: a ::backdrop pseudo-element appears behind the dialog but above everything else. You style it with CSS, with no extra <div>.
  3. Focus trapping: Tab and Shift+Tab cycle through the focusable elements inside the dialog without escaping to the page behind it.
  4. Escape key: pressing Escape fires a cancel event and closes the dialog by default.
  5. Inertness: everything behind the dialog becomes inert: unfocusable, unclickable and invisible to screen readers.

A <div> overlay provides none of these. You have to implement each one in JavaScript, and getting all five right at once, especially inertness and focus trapping for screen readers, is harder than it sounds.

This app's simplest dialog: the welcome modal

app/views/shared/_welcome_modal.html.erb is a <dialog> that opens automatically for a signed-in user who hasn't seen it yet:

<dialog id="welcome-modal"
        class="p-0 m-auto w-full max-w-xl bg-transparent backdrop:bg-black/50"
        aria-labelledby="welcome-modal-heading"
        data-controller="welcome-modal"
        data-action="close->welcome-modal#markSeen click->welcome-modal#backdrop">
  <div class="relative flex flex-col gap-6 rounded-2xl border border-border-subtle bg-card p-8 shadow-2xl">
    <button type="button" data-action="click->welcome-modal#dismiss" class="...">
      <svg aria-hidden="true"><!-- X icon --></svg>
      <span class="sr-only"><%= t("welcome_modal.close_aria") %></span>
    </button>
    <h2 id="welcome-modal-heading"><%= t("welcome_modal.heading") %></h2>
    <%# ... body content ... %>
  </div>
</dialog>

The Stimulus controller calls showModal() on connect. Persistence is wired to the dialog's own native close event, through data-action="close->welcome-modal#markSeen click->welcome-modal#backdrop", and not to the close button's click handler:

// app/javascript/controllers/welcome_modal_controller.js (trimmed to the close/persistence logic)
export default class extends Controller {
  connect() {
    this.marked = false
    this.show(0)
    this.element.showModal()
  }

  // Every close route lands here: the buttons, the backdrop, and Escape.
  markSeen() {
    if (this.marked) return
    this.marked = true
    fetch(this.urlValue, {
      method: "PATCH",
      headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken },
      body: JSON.stringify({ user_preference: { has_seen_welcome_modal: true } })
    })
  }

  dismiss() {
    this.element.close()
  }

  backdrop(event) {
    if (event.target === this.element) this.dismiss()
  }
}

That choice matters. A <dialog> also closes on Escape, which never reaches a click handler. Hooking persistence to the buttons alone would bring the modal back on the next visit for anyone who dismissed it with Escape, so markSeen listens for the dialog's own close event, which fires however the dialog closed.

The controller doesn't manage focus trapping, Escape handling or backdrop rendering. The browser does all of it because the code called showModal() instead of show().

Closing: close() and the close event

dialog.close() removes the dialog from the top layer, fires a close event and returns focus to the element that had it before showModal() was called. That last part happens automatically. With a <div> overlay, you have to save the previously focused element and restore focus by hand on close; with <dialog>, the browser tracks it for you.

Where this course goes

This first lesson introduced <dialog> as a replacement for <div> overlays: what the browser provides natively (top layer, backdrop, focus trap, Escape, inertness) and how this app's simplest dialog uses it. The remaining six lessons build outward:

  • Lesson 2: the ::backdrop pseudo-element and how to style it with CSS.
  • Lesson 3: focus management, meaning what "trapping" means, autofocus, and restoring focus on close.
  • Lesson 4: keyboard navigation, covering Escape, Tab order and preventing the page behind from scrolling.
  • Lesson 5: screen readers, with ARIA roles, labels and live regions.
  • Lesson 6: the reusable ui--dialog Stimulus controller pattern.
  • Lesson 7: putting it all together, the full lifecycle of an accessible dialog.
Lesson 1 of 7 · free

Keep going with lesson 2

Styling the `::backdrop` pseudo-element

Styling ::backdrop with Tailwind, three backdrop patterns in the app, color tokens and animating the backdrop.

A free account also gives you the quizzes, your progress and notes, and the discussion.

Type to start searching...