Vivian Voss

The Dialog Element

html css javascript web

Stack Patterns ■ Episode 04

“Modal or not modal: that is the method call.”

There exists, in the npm registry, a constellation of packages whose collective purpose is to open a box on screen, trap focus inside it, darken the background, close on Escape, and announce itself to screen readers. These packages enjoy 22.5 million installs per week. They are, by any commercial measure, a roaring success. They are also, by any engineering measure, entirely unnecessary.

The HTML <dialog> element does all of this natively. It ships in every browser. It has shipped in every browser since 2022. It weighs zero kilobytes. It requires zero configuration. It satisfies seven of the nine W3C accessibility requirements for a modal dialog before you have written a single line of code.

The industry, with characteristic thoroughness, has elected to reimplement it in JavaScript.

The Entire Pattern

Here is a confirmation dialog. The complete, production-ready, accessible confirmation dialog:

<dialog id="confirm">
  <form method="dialog">
    <p>Are you sure?</p>
    <button value="no">Cancel</button>
    <button value="yes" autofocus>Confirm</button>
  </form>
</dialog>

Opening it requires one line:

document.getElementById('confirm').showModal();

That is not a simplified excerpt. That is the entire implementation. The <form method=”dialog”> tells the browser to close the dialog when any button is pressed and to set the dialog’s returnValue to the button’s value attribute. No event listeners for the close action. No state management for open/closed. No cleanup. The platform handles the lifecycle.

What the Browser Provides Free of Charge

When you call .showModal(), the browser does the following, without being asked, without being configured, and without requiring a single dependency:

Focus management. Focus moves into the dialog automatically. When the dialog closes, focus returns to the element that opened it. Tab and Shift+Tab cycle through focusable elements inside the dialog and nowhere else. The focus trap that focus-trap implements in 2,036 lines of JavaScript is built into the browser as a default behaviour.

Inert background. Everything outside the dialog becomes inert. Not visually hidden. Inert. Clicks do not register. Tab does not reach it. Screen readers do not announce it. The page behind the dialog is, for all practical purposes, temporarily non-existent.

Escape closes. Press Escape. The dialog closes. No addEventListener(’keydown’). No event.key === ’Escape’. No checking whether another dialog is stacked on top. The browser handles it.

The ::backdrop pseudo-element. A styleable overlay behind the dialog. It exists automatically. It requires no additional DOM element, no absolute positioning, no z-index arithmetic. You style it with CSS and the browser renders it.

Top layer. The dialog renders in the top layer, above all other content, above all z-index stacking contexts, above absolutely everything. The z-index wars, the eternal arms race of z-index: 99999, simply do not apply. The top layer is a separate rendering plane. There is nothing to fight.

ARIA semantics. A modal dialog opened with .showModal() has role=”dialog” and aria-modal=”true” implicitly. Screen readers announce it correctly. The W3C specifies nine requirements for an accessible modal dialog. .showModal() satisfies seven before you write a line.

Return value. dialog.returnValue contains the value of the button that closed it. No custom events. No callback chains. No state variable tracking which button was pressed. The answer is on the element.

WHAT .showModal() PROVIDES FREE 7 of 9 W3C accessibility requirements, zero lines of code Focus management Auto-focus, tab cycling, return on close Inert background No clicks, no tab, no screen reader access Escape closes Native key handling, no event listener ::backdrop pseudo-element Styleable overlay, no extra DOM Top layer Above all z-index. No stacking context wars ARIA semantics role="dialog", aria-modal="true" implicit returnValue Which button closed it. On the element. Total cost: 0 KB

The One Thing It Does Not Do

Scroll lock. When a modal dialog opens, the background page remains scrollable. This is the single omission, the one genuine gap in the native implementation. The solution is two lines of CSS:

html:has(dialog[open]:modal) {
    overflow: hidden;
    scrollbar-gutter: stable;
}

The :has() selector targets the <html> element only when it contains an open modal dialog. No JavaScript. No toggle class on the body. No scroll position bookkeeping. The scrollbar-gutter: stable prevents the layout shift that occurs when the scrollbar disappears. Two declarations. Problem solved.

Two Methods, One Element

The <dialog> element has two opening methods, and the distinction between them is the entire architecture of the component:

.show() opens the dialog non-modally. No backdrop. No inert background. No focus trap. The page remains fully interactive. The dialog is an overlay, not a takeover. Think toast notifications, inline confirmations, supplementary panels.

.showModal() opens the dialog modally. Backdrop. Inert background. Focus trap. Escape closes. Top layer. ARIA semantics. The full ceremony. Think confirmation prompts, destructive action gates, authentication flows.

Same element. Same HTML. The method call decides the behaviour. No isModal prop. No configuration object. No conditional rendering. One element, two methods, two entirely different interaction patterns.

SAME ELEMENT, DIFFERENT METHOD The method call is the architecture .show() .showModal() Backdrop - Focus trap - Inert background - Escape closes - Top layer - ARIA modal semantics - Page interactive - Toast, inline panel → .show() ■ Confirm, auth, destructive → .showModal()

The Library Invoice

Let us examine what the ecosystem charges for the privilege of reimplementing a browser primitive.

focus-trap plus tabbable: 2,036 lines of JavaScript for focus management alone. That is the focus trap, the tab order analysis, the edge case handling for shadow DOM, the workaround for browser inconsistencies that were resolved years ago. Two thousand lines. To do what .showModal() does by default.

react-modal: 27 KB. Portal rendering, body class management, focus trapping, scroll locking, ARIA attributes, keyboard handling. Every one of these features is either built into <dialog> or achievable with two lines of CSS. The package has 2.4 million weekly downloads.

@radix-ui/react-dialog: 34 KB. A “headless” dialog component. Unstyled, composable, accessible. It is, by all accounts, beautifully engineered. It is also 34 kilobytes of JavaScript performing a job that the browser performs in zero kilobytes. The engineering is not in question. The necessity is.

The combined weekly downloads across these packages and their competitors exceed 22 million. That is 22 million npm install operations per week, fetching JavaScript to replicate what every browser has shipped natively for four years.

THE LIBRARY INVOICE What you download to do what the browser already does focus-trap + tabbable Focus management only 2,036 lines JS react-modal Full modal component 27 KB @radix-ui/react-dialog Headless dialog 34 KB Native <dialog> Focus, inert, escape, backdrop, ARIA, returnValue 0 KB Combined: 22.5 million npm installs/week to replicate a native element

The Timeline

Chrome shipped <dialog> in 2014. Version 37. Twelve years ago. For eight years, it sat behind a “Chrome only” asterisk while the rest of the browser vendors considered their options with no particular urgency.

Safari joined in March 2022 with version 15.4. Firefox followed in the same month with version 98. Eight years. Rather fashionably late, even by web standards committee standards.

Since March 2022, <dialog> has been available in every major browser. Global support stands at 96 per cent. That is higher than CSS :has(), higher than CSS nesting, higher than several features the industry ships without hesitation. The compatibility excuse expired two years ago. The packages, however, continue to accumulate downloads as though it had not.

The Styling

The aesthetic objection arrives on cue: “But the native dialog is ugly.” This is true in the same way that an unstyled <button> is ugly. The element is a semantic container. You style it:

dialog {
    border: 1px solid oklch(0.5 0 0);
    border-radius: 0.5rem;
    padding: 2rem;
    max-width: 28rem;
}

dialog::backdrop {
    background: oklch(0.1 0 0 / 0.6);
    backdrop-filter: blur(4px);
}

The ::backdrop pseudo-element is fully styleable. Blur, gradient, opacity, animation: whatever the design requires. It is not a polyfill. It is not a workaround. It is a first-class CSS primitive, rendered by the browser in the correct stacking context, with correct hit-testing, at zero performance cost.

What Remains

The native <dialog> element does not solve every conceivable modal requirement. It does not animate its opening and closing transitions natively, though CSS @starting-style and transition-behavior: allow-discrete are closing that gap. It does not provide built-in confirm/cancel callbacks, though returnValue and the close event cover the pattern. And it does not lock scroll, though two lines of CSS do.

What remains, after subtracting everything the browser provides natively, is not 27 KB of JavaScript. It is not 34 KB. It is not 2,036 lines of focus management. What remains is a handful of CSS declarations and the quiet realisation that the platform was ready before the industry was willing to notice.

22.5 million installs per week. For a box that opens, traps focus, and closes on Escape. The browser has done this since 2022. The npm registry, apparently, has not been informed.