Modals once had a bad reputation because they were so complicated to build from scratch. They were often buggy and had terrible usability, not to mention the many accessibility requirements that had to be met. To address these issues, about a decade ago, the <dialog>
element was introduced, along with supporting JavaScript methods and CSS properties. But, what if we could take it a step further by eliminating the need for JavaScript and using the new Popover API instead?
In this article, you’ll learn how to combine the Dialog API with the Popover API and a bit of CSS to create modals without JavaScript.
The Dialog and Popover approach asks for less code, fewer languages, and fewer files, so it’s much more manageable and less error-prone than using JavaScript.
Additionally, Node.js, frameworks, browser extensions, and even other snippets of JavaScript can interfere with a JavaScript approach to modals, potentially causing errors. To add to this, you often have to choose between deferring JavaScript or letting it block the rendering of the page.
<dialog>
and Popover? Aren’t they different things?Popovers are non-modal, meaning that users will still be able to interact with what’s underneath the top layer. This also means that the tab order won’t be contained to the popover. On the other hand, dialogs are modal if implemented correctly, meaning that users won’t be able to interact with the “bottom” layers. In terms of tab order, the focus will cycle through the focusable elements of the <dialog>
and browser UI only. To me, this is the key factor that determines whether I use a dialog or popover.
Something else to consider is the fact that dialogs are invoked using JavaScript, whereas popovers are fully HTML-based. But what if we want to develop a modal without JavaScript?
Well, I’ve discovered that you can use Dialog and Popover, as Dialog is an element and Popover is an attribute. This means that it’s possible to develop modals using just HTML (and CSS of course), and that’s exactly what you’ll learn how to do in this tutorial. I’ll also show you how to style the backdrop (::backdrop
) and even prevent scrolling using only CSS.
To understand how this approach works, we’ll code the modal using the <dialog>
element, which has been supported by web browsers for over a decade now, and JavaScript. We’ll then replace the JavaScript with the Popover API as well as some CSS so that you can see firsthand how much shorter the code is.
Let’s start with the <dialog>
element:
<dialog></dialog>
Next, add an id
to the <dialog>
element so that we can reference it using JavaScript:
<dialog id="dialogA"></dialog>
After that, add the <button>
that closes the <dialog>
:
<dialog id="dialogA"> <button class="closeDialog">Close dialogA</button> </dialog>
Pivoting a bit, code the <button>
that opens the <dialog>
. The value of the data-dialog
custom attribute should match the id
of the <dialog>
:
<button data-dialog="dialogA">Open dialogA</button>
Now, let’s move on to the JavaScript.
What’s happening here is that we’re selecting all elements with the data-dialog
attribute and attaching a click event listener to each one — this enables us to have as many modals as we want.
Whenever the user clicks on one of those buttons, we read the value of data-dialog
, select the modal that it corresponds to by matching the id
, and then store the node in a variable called dialog
. After that, we call the .showModal()
method on the dialog
node. If you opt to show the <dialog>
element in any other way, it will not be a modal dialog as the background will not be inert, which is fine if you don’t want a modal. However, the Popover API would be a better option in this case.
The next part of the code renders the document unscrollable by setting the CSS overflow
property to hidden
on the <html>
and <body>
. After that, we have another click event listener that closes the <dialog>
using the .close()
method and removes the overflow
property whenever the close button (.closeDialog
) is clicked:
/* Select and then loop all elements with the data-dialog custom HTML attribute */ document.querySelectorAll("[data-dialog]").forEach(button => { /* Make each one listen for a click */ button.addEventListener("click", () => { /* Match the value of data-dialog to the dialog with the same id value */ const dialog = document.querySelector(`#${ button.dataset.dialog }`); /* Show it! */ dialog.showModal(); /* Prevent scrolling */ document.body.style.overflow = "hidden"; document.documentElement.style.overflow = "hidden"; /* Listen for a click on the dialog's close button */dialog.querySelector(".closeDialog").addEventListener("click", () => { /* Close the dialog */ dialog.close(); /* Re-enable scrolling */ document.body.style.removeProperty("overflow"); document.documentElement.style.removeProperty("overflow"); }); }); });
See the Pen
<dialog> + JS (2/5* version) by Daniel Schwarz (@mrdanielschwarz)
on CodePen.
This now-standardized approach is miles better than any other solution that predates it simply because <dialog>
comes with a bunch of incredible features, including JavaScript methods (which we’ve already looked at), backdrops, and out-of-the-box accessibility — it’s the only semantic way to create modals. However, you do have to wonder why there weren’t HTML attributes that could make dialogs openable and closable, as well as the document unscrollable, without JavaScript.
Well, there are now, and that’s what we’re going to look at next.
First, let’s just get the CSS out of the way. The following CSS one-liner prevents the document (the <body>
, or what’s behind the popover) from being scrollable while it’s open. We’ve restricted this behavior to just dialogs for now:
body:has(dialog:popover-open) { overflow: hidden; }
This obviously wouldn’t work for dialogs that use the .showModal()
method. Instead, you’d need to use any of the following versions:
/* This one is more semantic */ body:has(dialog:modal) { overflow: hidden; } /* However, this one works just fine */ body:has(dialog[open]) { overflow: hidden; }
Anyway, our CSS one-liner replaces five lines of JavaScript:
document.querySelectorAll("[data-dialog]").forEach(button => { button.addEventListener("click", () => { const dialog = document.querySelector(`#${ button.dataset.dialog }`); dialog.showModal(); dialog.querySelector(".closeDialog").addEventListener("click", () => dialog.close()); }); });
<dialog>
toggleable using the Popover APINow, I’ll demonstrate how to eliminate the remaining JavaScript using the new Popover API. First, swap the data-dialog
custom HTML attribute for the popovertarget
attribute. There’s no need to change the value:
<button popovertarget="dialogA">Open dialogA</button>
Next, swap the class attribute and value of the close dialog button with the same popovertarget
attribute and value as above. That’s right, it’s the same button code for opening and closing the popover:
<dialog id="dialogA"> <button popovertarget="dialogA">Close dialogA</button> </dialog>
Finally, add the popover
attribute to the <dialog>
:
<dialog id="dialogA" popover> <button popovertarget="dialogA">Close dialogA</button> </dialog>
And, that’s it! The remaining JavaScript isn’t needed anymore.
However, popovers aren’t modal, remember? So we need to fix that.
Popovers aren’t modal and neither are dialogs unless you specifically open and close them with the .showModal()
and .close()
JavaScript methods.
This means that while we currently have a semantic HTML element (<dialog>
) (which is nice to have, I suppose, but in practice has no benefit at this current time) as well as the Popover API handling the ultra-lightweight implementation for the developer’s benefit, but we don’t have the accessibility benefits for the user. For example, the fact that our popovers aren’t modal means that the focus isn’t trapped to the dialog, so those interacting using a keyboard or assistive technology can accidentally fall out of the dialog and into the potentially blacked-out document.
W3C specifies that if a modal is visible, then the document must be inert. Here’s what that means:
<body>
must have the aria-hidden="true"
attribute and value so that assistive technologies are aware that a modal is open<body>
must have the pointer-events: none
CSS property and value so that interactables cannot be interacted with<body>
must have the user-select: none
CSS property and value so that text cannot be selectedcontenteditable
attribute whose value evaluates to something ‘truthy’) must be rendered uneditable<body>
tabindex: -1
attribute and value so that they cannot be focused uponA couple of those are definitely possible and we already have the CSS rule set up to implement them:
body:has(dialog:popover-open) { overflow: hidden; user-select: none; pointer-events: none; }
However, the document certainly isn’t inert at this point, so we have to take a different approach. Instead, we need to make the <body>
invisible, and of the three CSS properties that can do that, there’s only one that works as intended — visibility: hidden
. Just make sure to place your dialogs outside the main body of content, otherwise, it’ll become invisible too:
body:has(dialog:popover-open) { overflow: hidden; main { opacity: 0; /* Doesn't render the body inert */ display: none; /* Does, but causes content shift */ visibility: hidden; /* Does! */ } }
See the Pen
<dialog> + CSS-only (4/5* version) by Daniel Schwarz (@mrdanielschwarz)
on CodePen.
The question is whether we want the document (minus our dialog/popover) to disappear completely. In some cases, yes! You could make the backdrop opaque to mask the effect, in which case there’s nothing left to do here. We have a fully functional and accessible modal that doesn’t require any JavaScript.
However, if you prefer a translucent background and perhaps even a filter that blurs the background, what you’re looking for is the inert
HTML attribute, which provides all of the accessibility needed in a single attribute. The .showModal()
JavaScript method accomplishes the same thing. In this case, adding just a small JavaScript one-liner wouldn’t be so bad.
The JavaScript examples below (external and internal, depending on your preferences) demonstrate how to listen to your popover and toggle the inert
attribute every time the popover itself is toggled — and that’s it!
/* External JavaScript */ document.querySelectorAll("dialog[popover]").forEach(dialog => dialog.addEventListener("toggle", () => document.body.toggleAttribute("inert")));
<!-- Inline JavaScript —-> <dialog id="dialogA" popover ontoggle="document.body.toggleAttribute('inert')"> ... </dialog>
See the Pen
<dialog> + CSS + one line of JS (5/5* version) by Daniel Schwarz (@mrdanielschwarz)
on CodePen.
I’ve mentioned backdrops a few times, so here’s the deal regarding backdrops (::backdrop
) and popovers. Anyone who has used <dialog>
before will know that they only get access to backdrops when you utilize the .showModal()
JavaScript method, as is the case with many modal features, which we’ve seen in this article’s demonstrations.
What’s so great about popovers isn’t just that they can utilize backdrops like dialogs can, but that they don’t require JavaScript like dialogs do. For that reason, we can implement a backdrop on our modal with no problem. The example below adds a translucent black background with a blurry filter:
dialog:popover-open { filter: blur(5px); background: hsl(0 0 0 / 90%); }
The fact that popovers can have backdrops is odd. A backdrop signals that the bottom layers are inert/no longer functional, and that isn’t what popovers are supposed to do by nature. That being said, perhaps it’s an oversight and W3C didn’t anticipate that we’d combine Dialog with Popover.
If you provide the popover
attribute with the manual
value (so <dialog id="dialogA" popover="manual">
), you can prevent the modal from being closable by clicking on the backdrop. Great — in most cases that’s preferable to me, as I often accidentally close modals and lose my progress.
The problem with this is that it also prevents the modal from being closable by pressing the esc
key, a notable feature for people that operate websites using their keyboard. The best way to handle this is to forgo the manual
value and just ensure that progress isn’t lost when the modal gets closed (luckily, this is the default behavior anyway).
So there you have it — HTML/CSS-only accessible modals without JavaScript (or with just a tiny bit of JavaScript depending on what you’re trying to achieve aesthetically). JavaScript-free modals have many benefits — the code’s smaller, more robust, easier to manage, and it doesn’t block rendering. Plus, if you enjoy implementing web browser-supported cutting-edge features, then the Popover API is definitely worth exploring.
Got a question? Drop it in the comment section below, and thanks for reading!
As web frontends get increasingly complex, resource-greedy features demand more and more from the browser. If you’re interested in monitoring and tracking client-side CPU usage, memory usage, and more for all of your users in production, try LogRocket.
LogRocket is like a DVR for web and mobile apps, recording everything that happens in your web app, mobile app, or website. Instead of guessing why problems happen, you can aggregate and report on key frontend performance metrics, replay user sessions along with application state, log network requests, and automatically surface all errors.
Modernize how you debug web and mobile apps — start monitoring for free.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
Bypass anti-bot measures in Node.js with curl-impersonate. Learn how it mimics browsers to overcome bot detection for web scraping.
Handle frontend data discrepancies with eventual consistency using WebSockets, Docker Compose, and practical code examples.
Efficient initializing is crucial to smooth-running websites. One way to optimize that process is through lazy initialization in Rust 1.80.