Native dialog removed a 14KB modal library from 9 sites
method=dialog forms close and return values with zero JS
@starting-style plus closedby="any" handle exit animation and light-dismiss
One case (nested non-modal popovers) still needs a small helper
I deleted a 14KB modal library from 9 of my sites last quarter. The native `` element replaced every feature I was paying for: focus trap, top-layer stacking, escape-to-close, backdrop styling. Here are the 6 patterns I ship now, plus the one situation where I still reach for help.
The Free Focus Trap And Top-Layer Stacking
The reason most people install a modal library is the focus trap. When a dialog opens, keyboard focus needs to stay inside it. Tab past the last button and you should loop back to the first, not fall through to the page behind. Writing that by hand is a pain. You track focusable elements, listen for Tab and Shift+Tab, and handle the wrap. Libraries charge you kilobytes for it.
dialog.showModal() does the trap for free. Call it and the browser confines Tab to the dialog, sends Escape to close, and marks everything behind it inert so screen readers skip it. I tested this with VoiceOver on 3 of my product pages and the reading order was correct without a single ARIA attribute from me.
The second free thing is the top layer. When you open a modal dialog, the browser promotes it above every other element regardless of z-index. I used to fight stacking context bugs constantly, where a modal would sit behind a sticky header because some ancestor had transform set. The top layer ignores all of that. A modal dialog paints on top, period.
`javascript
Close
Your cart
document.querySelector('#cart').showModal();
`
Note the autofocus attribute. The browser moves initial focus to it when the dialog opens, which is the accessible default you want. Use show() instead of showModal() and you get a non-modal dialog: no backdrop, no focus trap, no inert background. I use the modal version for anything that demands a decision (checkout confirm, age gate) and the non-modal version for things that should not block the page. If you came up through the era where this all needed JavaScript, my notes on 5 CSS Animations That Needed JavaScript Until 2026 cover the same shift in mindset.
That is two of the three big reasons people install a library, gone, with two method calls.
Styling ::backdrop And The method=dialog Form Trick
The dimmed overlay behind a modal has its own pseudo-element: ::backdrop. You style it like any selector. No extra div, no positioned overlay that you have to z-index above the content.
`css
dialog::backdrop {
background: rgb(0 0 0 / 0.6);
backdrop-filter: blur(4px);
}
`
I run a 4px blur on the backdrop across all my storefronts. It costs one line and makes the modal feel intentional instead of bolted on. You can animate ::backdrop too, which matters for the exit animation pattern below.
The form trick is the part people miss. A inside a dialog closes it on submit and records which button was used, all without JavaScript.
`plaintext
Delete this draft?
Cancel
Delete
`
When the user clicks Delete, the dialog closes and dialog.returnValue becomes "delete". You read it in a single close event listener:
`javascript
confirm.addEventListener('close', () => {
if (confirm.returnValue === 'delete') removeDraft();
});
`
That is a complete confirm dialog with two outcomes and almost no script. I shipped exactly this for a destructive action on one of my Shopify admin tools and it replaced about 40 lines of state handling. If you build on Shopify, this pattern slots right into theme app blocks because it needs nothing global.
The catch worth knowing: method="dialog" only closes the dialog, it does not submit data anywhere. If you need a real network submit, keep a normal form and call dialog.close() yourself after the fetch resolves. I mixed the two up early on and spent 20 minutes confused why my POST never fired. The fix was one attribute. For the broader pattern of moving logic out of JavaScript and into the platform, CSS :has() in Production walks through the same trade-offs on the selector side.
Light-Dismiss With closedby And Clean Exit Animations
For years the standard request was "let me click outside the modal to close it." That meant a click listener on the backdrop, math to check the click target, and an edge case where clicking inside and dragging out would close it by accident. Now there is an attribute.
`plaintext
...
`
closedby="any" gives you light-dismiss: click the backdrop or press Escape and the dialog closes. closedby="closerequest" allows only Escape and the platform close gesture. closedby="none" blocks both, which I use for a payment-in-progress modal that must not be dismissed mid-transaction. Three values, zero listeners. This shipped to stable Chrome and is rolling through the other engines, so I feature-detect and fall back to a tiny backdrop-click handler where it is missing.
Exit animations were the other thing libraries handled. The problem: when you set display: none the element vanishes instantly, so there is nothing to animate out. The new combo is @starting-style for the entry and transition-behavior: allow-discrete for the exit.
`css
dialog {
opacity: 1;
transition: opacity 0.2s, overlay 0.2s allow-discrete, display 0.2s allow-discrete;
}
dialog:not([open]) { opacity: 0; }
@starting-style {
dialog[open] { opacity: 0; }
}
`
@starting-style defines the values the dialog starts from before it opens, so it fades in. allow-discrete on display and overlay keeps the element painted through the exit transition instead of cutting it the instant the attribute drops. The overlay property is what keeps it in the top layer until the fade finishes. I run a 200ms fade plus an 8px translate on every dialog now and it reads as polished without a single requestAnimationFrame call.
This is the same family of platform animation work I covered in CSS Scroll-Driven Animations: 6 Patterns I Ship in 2026, and the discrete-transition trick shows up there too. If you want the whole animation story without JS, View Transitions API: 5 Patterns pairs cleanly with dialogs for full-screen route changes.
The One Case Where A Library Still Wins
I want to be honest about the limit, because pretending native covers everything would cost you a debugging afternoon. The element handles single modals beautifully. It struggles with deeply nested, non-modal stacks where multiple floating panels need to coexist and dismiss in a specific order.
Picture a non-modal dialog that contains a select, and that select opens a custom dropdown, and the dropdown has a tooltip on one option. With show() (non-modal), there is no focus trap and no inert background, so you are back to managing focus order and outside-click dismissal across three independent layers. The native top layer stacks them, but it does not orchestrate which one Escape should close first or how focus should return down the chain. That orchestration is exactly what a small library like a focus-management helper gives you, and it is worth the 4KB in that narrow case.
I hit this building a filter panel for a catalog with 1,200 SKUs. The panel was non-modal so shoppers could keep scrolling the grid, but it held nested expandable groups with their own popovers. Native dialog plus the popover API got me 90% there. The last 10%, the predictable Escape order across nested popovers, was flaky enough that I added a 3KB helper just for focus return. Everything else stayed native.
The honest rule I use: if it is one modal at a time, native wins outright and I write zero JavaScript for behavior. If I have two or more interactive floating layers that must coordinate dismissal and focus, I keep a tiny helper and let it manage only that coordination, not the rendering.
A few smaller gotchas I logged so you do not repeat them. A dialog with no autofocus and no focusable child puts focus on the dialog itself, which is fine but means Escape works and Tab does nothing visible, confusing in testing. Setting max-height matters because long dialogs can overflow the viewport with no scroll. And the default dialog has a centered position via margin auto that you will likely override. For a related layout problem, anchoring a small panel to a trigger, CSS Anchor Positioning Is Production-Ready is the piece I reach for instead of a dialog entirely.
Bottom Line
The native `` element removed a dependency I had carried for years. Focus trap, top-layer stacking, backdrop styling, escape-to-close, light-dismiss, return values, and exit animations all live in the platform now. I ship 5 of the 6 patterns with zero behavior JavaScript and reach for a 3KB helper only when multiple floating layers must coordinate.
If you are still loading a modal library, open one component and try the swap. Replace the open call with showModal(), delete your focus-trap code, add a ::backdrop rule, and wire one close listener. On my sites that swap took an afternoon per component and cut real bytes off every page that rendered a modal.
I document each of these platform-over-library calls as I make them, because cutting dependencies is most of how a one-person studio stays fast. If you want the system I use to decide what to build, what to delete, and how I keep it consistent across repos, the Claude Blueprint lays out the whole workflow. Start with the dialog swap, measure the bytes you drop, and keep the helper only where the platform genuinely runs out.
This article contains affiliate links. If you sign up through them, I may earn a small commission at no extra cost to you. (Ad)
Top comments (0)