Structural sibling to CSS Doesn't Need More Power — It Needs Better Boundaries — same distinction, different layer.
Also available in Español
The Problem
A modal opens. A screen reader user tabs forward through it the way any keyboard-only interaction is supposed to work: contained, predictable, one control after another until the modal closes. Then a request finishes, a new option renders inside the modal, and the next Tab press lands somewhere it was never supposed to reach — a link in the page behind the modal, still visible under a dimmed overlay, still technically focusable.
Nobody removed the focus trap. It's still running, still listening for Tab and Shift+Tab, still doing exactly what it did the moment the modal opened. What changed is the DOM underneath it. The trap was built around a set of focusable elements captured once, at open time. The modal's actual content didn't stay that simple.
Why the Problem Exists
For a long time, HTML had no element that meant modal dialog. A <div> styled to look like one is still just a div — no keyboard contract, no way to tell the browser it should trap focus, dim the page behind it, or return focus to whatever was focused before it opened. Building that behavior in JavaScript wasn't a mistake. It was the only option available, and plenty of production interfaces still run on the version built during that gap.
But building the behavior means owning it. A hand-rolled focus trap has to enumerate every focusable element inside the modal, redirect focus at the boundaries, keep the background inert, and restore focus correctly on close. None of that is a one-time cost. It's an ongoing correctness obligation, because whatever was true about the modal's contents when the code was written isn't guaranteed to stay true every time the modal opens in production.
The First Principle
The browser is not only a renderer. For a defined set of native elements and browser APIs, it also implements the interaction contract that goes with them — what happens on open, what happens on close, where focus goes, what stays reachable by keyboard and what doesn't. A native <select> has always worked this way; nobody hand-rolls keyboard navigation for a dropdown built from the actual element. <dialog> extends the same idea to modal interfaces.
That contract lives in the platform, which means the browser is responsible for keeping it correct as its own implementation changes across versions and devices. Application code that reimplements the same contract doesn't just write more code. It takes on an obligation the platform was already fulfilling, without inheriting the resources the platform has to keep fulfilling it.
Demonstrating the Principle
Strip a custom modal down to what it actually has to coordinate:
opening state
focus placement on open
focus containment while open
dismissal (Escape, backdrop click)
focus restoration on close
inert background while open
visual stacking above the rest of the page
Every line on that list is application-owned work — something the code has to get right, keep right, and re-verify whenever the modal's content changes.
<dialog> moves each of those into the platform's contract:
<dialog id="preferences">
<form method="dialog">
<p>Update your preferences.</p>
<button autofocus>Save</button>
</form>
</dialog>
dialog::backdrop {
background: rgb(0 0 0 / 50%);
}
document.getElementById('preferences').showModal();
showModal() opens the dialog, promotes it to the browser's top layer above the rest of the page, makes everything outside it inert, and contains focus inside it automatically. Clicking Save needs no click handler to close anything — method="dialog" means submitting the form closes the dialog and restores focus to whatever was focused before it opened, on its own. Escape does the same without any code at all. The dimmed background behind it isn't a separate element the application built and had to stack correctly; ::backdrop is a pseudo-element the browser generates for exactly this purpose the moment the dialog goes modal. For cases with no form to submit — closing from a timeout, or a control that lives outside the dialog — .close() provides the same effect imperatively.
The application declares what it wants: this is a dialog, open it modally, let native submission and Escape close it. The browser owns the mechanics in between.
The difference isn't line count. A well-written custom focus trap and a <dialog> call might not differ by much in a diff. The difference is who's on the hook the next time the modal's contents change — the platform, which already accounts for a dynamic DOM inside a native dialog, or the application, which has to notice the assumption broke and go fix it.
The Pain Point
A production modal carried roughly 200 lines of hand-rolled focus-management logic: enumerate focusable children on open, redirect Tab and Shift+Tab at the first and last of them, restore focus on close. It worked, and it had worked for a while.
The failure traced back to one assumption baked into that logic: the set of focusable elements inside the modal was captured once, at open time, and treated as fixed for as long as the modal stayed open. That held for the modal's original content. It stopped holding the moment a variant of the modal started loading additional options asynchronously after open — the trap kept redirecting focus against a list of elements that no longer matched what was actually on screen, and focus could walk straight out of the modal into the page behind it.
The application had written a static algorithm around a document that wasn't static. That's not a coding mistake in the ordinary sense. It's the ongoing cost of owning a contract the platform already defines: every assumption the code makes about the DOM has to stay true indefinitely, or the code has to keep being updated to match.
Saturday's Notes from the Pass removes the hand-rolled trap, rebuilds the same modal on <dialog>, and traces exactly which responsibilities move to the browser and which ones don't — because native <dialog> ends the focus-trap maintenance burden, but it doesn't make every remaining decision about the dialog's contents automatic.
The Broader Lesson
This isn't an argument that JavaScript shouldn't touch modals, or that native elements are always the right call. Plenty of interactive patterns genuinely have no native equivalent, and application code is the only place that behavior can live. The question worth asking first is narrower: does this behavior already have an owner?
Article 3 asked where a structural decision belongs when the context needed to make it is split across component boundaries. This is the same question turned toward behavior instead of structure — who owns an interaction contract when the platform already defines one. In both cases, the mistake isn't building something. It's building something without first checking whether it already exists, fully specified, one layer down.
Every system accumulates code that reimplements something a lower layer already provides, usually because nobody checked before writing it. The check costs a few minutes. Maintaining an incorrect assumption about a contract you didn't need to own costs a great deal more, indefinitely, until someone notices.
Top comments (0)