DEV Community

You built your modal with a and a focus trap library. The native does all of that.

Building a modal from scratch means writing the same boilerplate every time: a with role="dialog" , an aria-modal attribute, a tabindex="-1" to steal focus, a keydown listener to catch Escape, a focus-trap library to keep Tab cycling inside the dialog, and a click listener on the backdrop overlay you rendered yourself. It works, but you're doing the browser's job. The element exists to take that job back. The baseline API Delete item? This cannot be undone. Cancel Confirm Delete const dialog = document.getElementById('confirm-dialog'); const openBtn = document.getElementById('open-btn'); const cancelBtn = document.getElementById('cancel-btn'); const confirmBtn = document.getElementById('confirm-btn'); openBtn.addEventListener('click', () => dialog.showModal()); cancelBtn.addEventListener('click', () => dialog.close()); confirmBtn.addEventListener('click', () => { deleteItem(); dialog.close(); }); That's it. The browser handles focus trapping (Tab stays inside), Escape-to-close, and ARIA role. No library. No keydown listener. No backdrop div. showModal() vs show() The element has two open methods and they are meaningfully different. dialog.show() opens the dialog as a non-modal: it's visible, but the rest of the page is still interactive. Useful for inline panels, drawers, or toasts - not for blocking confirmation flows. dialog.showModal() opens it as a modal: the browser places the dialog in the top layer - above all other content, including elements with high z-index - and blocks interaction with everything beneath. Focus is trapped inside. Escape closes it. This is what you actually want for a modal dialog. dialog.show(); // non-modal - rest of page still interactive dialog.showModal(); // modal - top layer, focus trapped, Escape works Styling the backdrop When opened with showModal() , the browser renders a backdrop behind the dialog and above the rest of the page. You style it with the ::backdrop pseudo-element: dialog::backdrop { background: rgb(0 0 0 / 50%); backdrop-filter: blur(4px); } No more position: fixed; inset: 0; background: rgba(0,0,0,0.5) divs. The browser-rendered backdrop is always in the right place, covers the right things, and is animated by the dialog's own open/close transitions. Animating open and close The element pairs naturally with @starting-style for entry animations. For the exit, you need a small JS helper because the dialog's close() method removes the open attribute before a CSS exit transition can play: dialog { opacity: 0; transform: scale(0.95); transition: opacity 200ms, transform 200ms, display 200ms allow-discrete; } dialog[open] { opacity: 1; transform: scale(1); } @starting-style { dialog[open] { opacity: 0; transform: scale(0.95); } } With allow-discrete on the display transition (Chrome 117+), the browser holds the dialog in the layout for the duration of the exit transition before hiding it. For older targets, a small setTimeout before dialog.close() is the reliable fallback. Handling backdrop clicks to close The backdrop is not a separate element you can listen to directly. The reliable pattern uses the dialog's own click event and checks whether the click landed inside the dialog's bounding box: dialog.addEventListener('click', (event) => { const rect = dialog.getBoundingClientRect(); const clickedOutside = event.clientX rect.right || event.clientY rect.bottom; if (clickedOutside) dialog.close(); }); The reason this works: when you click the backdrop, the element itself receives the event - the click target is the dialog, not an element inside it. The bounding box check distinguishes backdrop clicks from content clicks cleanly. Return value from close() dialog.close() accepts an optional string argument that becomes the dialog's returnValue . This lets you communicate why the dialog closed without external state: confirmBtn.addEventListener('click', () => dialog.close('confirmed')); cancelBtn.addEventListener('click', () => dialog.close('cancelled')); dialog.addEventListener('close', () => { if (dialog.returnValue === 'confirmed') deleteItem(); }); The close event fires whenever the dialog closes - via close() , Escape, or a submission. Combined with returnValue , it gives you a clean interface for confirmation flows without threading state through callbacks. The shortcut If you put a inside a , any submit button closes the dialog automatically and sets returnValue to the button's value attribute - no JavaScript needed for the close logic: Delete item? Cancel Confirm dialog.addEventListener('close', () => { if (dialog.returnValue === 'confirmed') deleteItem(); }); The form doesn't submit to a server - method="dialog" is a special value that routes the submission back to the dialog element itself. Useful for simple flows; for complex forms with validation, you'll still handle submission explicitly. Browser support is Baseline 2022: Chrome 98, Firefox 98, Safari 15.4. Global support is above 95%. You do not need a polyfill for production today. The only missing piece in some older browsers is ::backdrop styling - the functional modal behavior (focus trap, Escape, top layer) is universally supported. ๐ŸŽฎ Try it yourself โ–ถ๏ธ Open the interactive playground โ†’ Runs right in your browser - poke at it and watch the concept react live. ๐Ÿง  Test yourself Think it clicked? Take the 8-question quiz โ†’ Instant feedback, a hint on every question, and an explanation for each answer - right or wrong. The takeaway Search your codebase for role="dialog" and the focus-trap imports that live near it. Each one is a candidate for replacement: with showModal() handles focus trapping, Escape, backdrop, and ARIA semantics - a library bought you those because the platform didn't provide them. It does now. Start with the simplest confirmation dialog in your UI, replace it with and showModal() , and you'll notice what disappears: the keydown listener, the backdrop div, the z-index war, and the focus-management ceremony. What remains is the logic that was actually yours to write. Thanks for reading! Let's stay connected: - โญ GitHub - follow me and star the projects: github.com/parsajiravand - ๐Ÿ’ฌ Discord - join the frontend best-practices community: discord.gg/d9KRhuAwQ - ๐Ÿ“ธ Instagram - frontend best practices, daily: @bestpractice___ - ๐Ÿ’ผ LinkedIn - linkedin.com/in/parsa-jiravand - โœ‰๏ธ Email (work & contract inquiries): bes*************@gmail.com Top comments (0)

Comments

No comments yet. Start the discussion.