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.
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.
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 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.