Ditching Modal Libraries for Native Dialog and Popover
How to replace third-party modal libraries with the native HTML dialog element and the popover attribute, including API shapes, accessibility, and styling limits.

For years, building a modal meant pulling in a third-party library: React Modal, Bootstrap's modal, or a jQuery plugin. Most of those libraries do exactly two things—show a layered overlay and trap focus—yet they add bundle weight and break when the host framework changes its lifecycle. The native HTML <dialog> element and the popover attribute now ship in every major browser, and they handle the core mechanics without JavaScript dependencies. This article walks through their APIs, styling limits, and when to use each.
Why the native dialog element is production-ready now
<dialog> has been in Chrome since version 37 (2014) and in Firefox since version 98 (2022). Safari shipped support in Safari 15.4 (March 2022), and the remaining gap is only legacy browsers like Internet Explorer and older Safari-on-iOS versions. If your analytics show less than 2% traffic on Safari < 15.4, you can safely drop polyfills.
The element solves two problems that every modal library has to implement from scratch: focus trapping and Escape-key dismissal. When you call .showModal(), the browser locks focus inside the dialog and dismisses it on Escape—no keydown listener needed. The ::backdrop pseudo-element provides a full-screen overlay you can style with CSS alone, without adding a separate <div> for the backdrop.
<!-- No JavaScript needed for the overlay -->
<style>
dialog::backdrop {
background: rgba(0,0,0,0.6);
backdrop-filter: blur(2px);
}
</style>
<dialog id="my-dialog">
<p>Content</p>
<button id="close-btn">Close</button>
</dialog>
<script>
const dialog = document.getElementById('my-dialog');
document.getElementById('show-btn').addEventListener('click', () => dialog.showModal());
document.getElementById('close-btn').addEventListener('click', () => dialog.close());
</script>This pattern eliminates the library’s dependency on a focus-manager and a backdrop component.
Basic dialog usage: open, modal, and close mechanics
<dialog> has two opening methods: .showModal() for a modal dialog (blocks interaction with the rest of the page) and .show() for a non-modal dialog that lets users interact with other elements. The distinction is critical—showModal() is the one you want for confirmations and form overlays.
Closing works via three paths: programmatic .close(), the Escape key (when modal), or a <form method="dialog"> inside the dialog. When a form with method="dialog" is submitted, the dialog automatically closes and sets dialog.returnValue to the submit button’s value. This is the cleanest way to handle simple confirmations without a submit event handler.
<dialog id="confirm-dialog">
<form method="dialog">
<p>Delete this record?</p>
<button value="cancel" formmethod="dialog">Cancel</button>
<button value="confirm">Delete</button>
</form>
</dialog>
<script>
const dialog = document.getElementById('confirm-dialog');
dialog.showModal();
dialog.addEventListener('close', () => {
if (dialog.returnValue === 'confirm') {
// perform delete
}
});
</script>The close event fires when the dialog is dismissed for any reason. dialog.returnValue is an empty string if dismissed via Escape unless you set it manually. For a method="dialog" form, the value comes from the clicked submit button.
The popover attribute for lightweight overlays
The popover attribute (shipping in Chrome 114+, Firefox 114+, Safari 17+) provides a non-modal overlay with built-in light-dismiss mechanics. popover="auto" makes an element pop over other content and dismiss when the user clicks outside or presses Escape. You control it declaratively with popovertarget on a button—no JavaScript required for basic toggling.
<button popovertarget="my-popover">Toggle menu</button>
<div id="my-popover" popover="auto">
<ul>
<li>Item 1</li>
<li>Item 2</li>
</ul>
</div>Clicking the button toggles the popover attribute’s open state. The popover appears in the top layer, above any z-index-stacked elements. Because it does not trap focus, it’s more suitable for transient UI like dropdowns, tooltips, and notification lists. You can animate it using @starting-style (Chrome 117+) or fall back to CSS transition on the display property (covered later).
Popovers stack in a light dismiss layer. When multiple popovers are open, only the topmost can be interacted with. They do not set aria-modal or trap focus—screen readers continue to navigate the rest of the page, which is appropriate for a menu but problematic for a critical confirmation.
Dialog vs popover: choosing the right primitive
| Feature | <dialog> (modal) |
<popover> (auto) |
|---|---|---|
| Focus trapping | Yes (when modal) | No |
| Escape dismissal | Yes | Yes |
| Outside click dismissal | No (modal blocks outside clicks entirely) | Yes (light dismiss) |
| Programmatic show/hide | .show(), .showModal(), .close() |
showPopover(), hidePopover(), togglePopover(), or declarative popovertarget |
::backdrop pseudo-element |
Yes | Yes (via ::backdrop on the popover element) |
| Best use case | Confirmations, forms, consent banners | Dropdowns, tooltips, notification lists |
If you need to block user interaction until a choice is made, use dialog.showModal(). If you need a transient overlay that should disappear when the user clicks somewhere else, use popover (or a non-modal dialog with manual outside-click handling). You can nest a dialog inside a popover or vice versa, but then you own the focus and z-index management again—not recommended without a good reason.
Styling and animation gotchas
Both dialog and popover render in the top layer, a special stacking context above everything with z-index. This means a fixed-position header will not overhang a modal overlay—the top layer always wins. If you need the overlay to appear below a navigation bar, you cannot use the native top layer. You would have to revert to a library that positions the element in the normal document flow with a high z-index. For most projects this is not an issue.
Animations on open and close are where most people hit a wall. The browser does not animate the ::backdrop or the element itself when the open attribute is toggled. The simplest workaround is @starting-style (Chrome 117+), which lets you define styles before the element is rendered and then transition to the final state.
dialog {
transition: display 0.3s allow-discrete, overlay 0.3s allow-discrete;
opacity: 0;
}
dialog[open] {
opacity: 1;
}
@starting-style {
dialog[open] {
opacity: 1;
}
}Without @starting-style, you must resort to JavaScript animations: wait for the transitionend event after setting styles, or use a CSS animation that starts on [open]. The allow-discrete keyword enables transitioning from display: none to display: block; it is supported in Chrome 117+ and Firefox 120+. Safari does not yet support allow-discrete (as of Safari 18), so a JavaScript fallback is still advisable for Safari users.
For more on cross-browser experimental CSS, see our article on CSS Container Queries in Production: Setup, Gotchas, and Limits.
Accessibility and keyboard behavior
When you call .showModal(), the browser sets aria-modal="true" on the dialog and moves focus to the first focusable element inside it. This works well with modern screen readers. However, popover does not set aria-modal—it is intended for non-modal contexts. If you misuse a popover for a critical confirmation, screen reader users may not realise they are in a modal context.
Always provide a visible, focusable close button inside the dialog or popover. Relying solely on Escape dismissal excludes keyboard users who may not know the shortcut. Test with VoiceOver on Safari and NVDA on Chrome to confirm focus management; older versions of NVDA (prior to 2021) did not announce the dialog role properly, but current versions do.
If you need a modal but cannot use <dialog> due to browser support, consider the approach described in Fixing INP in React Apps That Already Pass LCP for lightweight custom solutions.
Polyfills and migration from a library like React Modal
The dialog-polyfill by Google Chrome Labs is the most commonly used polyfill. It mimics showModal() and ::backdrop on browsers that don’t support <dialog>, but it does not render the dialog in the top layer. Instead, it places it in the normal document flow with a high z-index. This means your CSS must handle z-index stacking manually, and you lose the top-layer guarantee. For legacy browsers, test whether the polyfill’s behavior is acceptable.
Migrating a React Modal component to native <dialog> is straightforward. Replace the library’s open/close state with a ref to a <dialog> element, then call .showModal() and .close() directly. React’s synthetic event system works fine; you just need to ensure the dialog is not unmounted while open.
function ConfirmDialog({ isOpen, onConfirm, onCancel }) {
const dialogRef = useRef(null);
useEffect(() => {
if (isOpen) {
dialogRef.current?.showModal();
} else {
dialogRef.current?.close();
}
}, [isOpen]);
return (
<dialog ref={dialogRef} onClose={onCancel}>
<form method="dialog">
<p>Are you sure?</p>
<button value="cancel">Cancel</button>
<button value="confirm" onClick={onConfirm}>Confirm</button>
</form>
</dialog>
);
}Watch for body scroll locking: <dialog> does not prevent scrolling on the background page on mobile by default. Adding overflow: hidden to <body> when the dialog is open is a common fix. The same applies to popovers.
If your project uses React and you need more advanced state management patterns like server components and client islands, see our guide on Server components vs client islands in Next.js App Router.
Key takeaways
<dialog>with.showModal()replaces most modal library use cases—focus trapping, Escape dismissal, and backdrop are built in.popover="auto"provides light-dismiss overlays without JavaScript, ideal for menus and tooltips. Do not use it for modals.- Animating open/close requires
@starting-styleor JavaScript; the top layer impedes z-index stacking. - Always include a close button and test with screen readers—native elements handle accessibility well in modern browsers but not in legacy versions.
- Polyfills exist for
<dialog>but lack top-layer support; they are only necessary for browsers below Safari 15.4.
Frequently asked questions
- Can I use the dialog element with React or Vue?
- Yes. You interact with it imperatively via refs—call .showModal() and .close() in event handlers. React's state management complements it well for controlling content and return values.
- Does the popover attribute work on mobile browsers?
- Popover is supported in Chrome and Safari on iOS since mid-2023 and in Android Chrome. Firefox mobile support arrived in version 125. Test on your target devices; the API behavior is consistent across platforms.
- How do I animate the dialog's open and close?
- The simplest approach is to use CSS transitions on opacity and transform, combined with @starting-style for the initial state. Alternatively, use a small JavaScript timeout to add a class after showModal() to trigger an animation.


