If all you have is a hammer, everything looks like a nail.
Abraham Maslow
It’s easy to default to what you know. When it comes to toggling content, that might be reaching for display: none
or opacity: 0
with some JavaScript sprinkled in. But the web is more “modern” today, so perhaps now is the right time to get a birds-eye view of the different ways to toggle content — which native APIs are actually supported now, their pros and cons, and some things about them that you might not know (such as any pseudo-elements and other non-obvious stuff).
So, let’s spend some time looking at disclosures (<details>
and <summary>
), the Dialog API, the Popover API, and more. We’ll look at the right time to use each one depending on your needs. Modal or non-modal? JavaScript or pure HTML/CSS? Not sure? Don’t worry, we’ll go into all that.
Disclosures (<details>
and <summary>
)
Use case: Accessibly summarizing content while making the content details togglable independently, or as an accordion.
Going in release order, disclosures — known by their elements as <details>
and <summary>
— marked the first time we were able to toggle content without JavaScript or weird checkbox hacks. But lack of web browser support obviously holds new features back at first, and this one in particular came without keyboard accessibility. So I’d understand if you haven’t used it since it came to Chrome 12 way back in 2011. Out of sight, out of mind, right?
Here’s the low-down:
- It’s functional without JavaScript (without any compromises).
- It’s fully stylable without
appearance: none
or the like. - You can hide the marker without non-standard pseudo-selectors.
- You can connect multiple disclosures to create an accordion.
- Aaaand… it’s fully animatable, as of 2024.
Marking up disclosures
What you’re looking for is this:
<details>
<summary>Content summary (always visible)</summary>
Content (visibility is toggled when summary is clicked on)
</details>
Behind the scenes, the content’s wrapped in a pseudo-element that as of 2024 we can select using ::details-content
. To add to this, there’s a ::marker
pseudo-element that indicates whether the disclosure’s open or closed, which we can customize.
With that in mind, disclosures actually look like this under the hood:
<details>
<summary><::marker></::marker>Content summary (always visible)</summary>
<::details-content>
Content (visibility is toggled when summary is clicked on)
</::details-content>
</details>
To have the disclosure open by default, give <details>
the open
attribute, which is what happens behind the scenes when disclosures are opened anyway.
<details open> ... </details>
Styling disclosures
Let’s be real: you probably just want to lose that annoying marker. Well, you can do that by setting the display
property of <summary>
to anything but list-item
:
summary {
display: block; /* Or anything else that isn't list-item */
}
Alternatively, you can modify the marker. In fact, the example below utilizes Font Awesome to replace it with another icon, but keep in mind that ::marker
doesn’t support many properties. The most flexible workaround is to wrap the content of <summary>
in an element and select it in CSS.
<details>
<summary><span>Content summary</span></summary>
Content
</details>
details {
/* The marker */
summary::marker {
content: "f150";
font-family: "Font Awesome 6 Free";
}
/* The marker when <details> is open */
&[open] summary::marker {
content: "f151";
}
/* Because ::marker doesn’t support many properties */
summary span {
margin-left: 1ch;
display: inline-block;
}
}
Creating an accordion with multiple disclosures
To create an accordion, name multiple disclosures (they don’t even have to be siblings) with a name
attribute and a matching value (similar to how you’d implement <input type="radio">
):
<details name="starWars" open>
<summary>Prequels</summary>
<ul>
<li>Episode I: The Phantom Menace</li>
<li>Episode II: Attack of the Clones</li>
<li>Episode III: Revenge of the Sith</li>
</ul>
</details>
<details name="starWars">
<summary>Originals</summary>
<ul>
<li>Episode IV: A New Hope</li>
<li>Episode V: The Empire Strikes Back</li>
<li>Episode VI: Return of the Jedi</li>
</ul>
</details>
<details name="starWars">
<summary>Sequels</summary>
<ul>
<li>Episode VII: The Force Awakens</li>
<li>Episode VIII: The Last Jedi</li>
<li>Episode IX: The Rise of Skywalker</li>
</ul>
</details>
Using a wrapper, we can even turn these into horizontal tabs:
<div> <!-- Flex wrapper -->
<details name="starWars" open> ... </details>
<details name="starWars"> ... </details>
<details name="starWars"> ... </details>
</div>
div {
gap: 1ch;
display: flex;
position: relative;
details {
min-height: 106px; /* Prevents content shift */
&[open] summary,
&[open]::details-content {
background: #eee;
}
&[open]::details-content {
left: 0;
position: absolute;
}
}
}
…or, using 2024’s Anchor Positioning API, vertical tabs (same HTML):
div {
display: inline-grid;
anchor-name: --wrapper;
details[open] {
summary,
&::details-content {
background: #eee;
}
&::details-content {
position: absolute;
position-anchor: --wrapper;
top: anchor(top);
left: anchor(right);
}
}
}
If you’re looking for some wild ideas on what we can do with the Popover API in CSS, check out John Rhea’s article in which he makes an interactive game solely out of disclosures!
Adding JavaScript functionality
Want to add some JavaScript functionality?
// Optional: select and loop multiple disclosures
document.querySelectorAll("details").forEach(details => {
details.addEventListener("toggle", () => {
// The disclosure was toggled
if (details.open) {
// The disclosure was opened
} else {
// The disclosure was closed
}
});
});
Creating accessible disclosures
Disclosures are accessible as long as you follow a few rules. For example, <summary>
is basically a <label>
, meaning that its content is announced by screen readers when in focus. If there isn’t a <summary>
or <summary>
isn’t a direct child of <details>
then the user agent will create a label for you that normally says “Details” both visually and in assistive tech. Older web browsers might insist that it be the first child, so it’s best to make it so.
To add to this, <summary>
has the role
of button
, so whatever’s invalid inside a <button>
is also invalid inside a <summary>
. This includes headings, so you can style a <summary>
as a heading, but you can’t actually insert a heading into a <summary>
.
The Dialog element (<dialog>
)
Use case: Modals
Now that we have the Popover API for non-modal overlays, I think it’s best if we start to think of dialogs as modals even though the show()
method does allow for non-modal dialogs. The advantage that the popover
attribute has over the <dialog>
element is that you can use it to create non-modal overlays without JavaScript, so in my opinion there’s no benefit to non-modal dialogs anymore, which do require JavaScript. For clarity, a modal is an overlay that makes the main document inert, whereas with non-modal overlays the main document remains interactive. There are a few other features that modal dialogs have out-of-the-box as well, including:
- a stylable backdrop,
- an autofocus onto the first focusable element within the
<dialog>
(or, as a backup, the<dialog>
itself — include anaria-label
in this case), - a focus trap (as a result of the main document’s inertia),
- the
esc
key closes the dialog, and - both the dialog and the backdrop are animatable.Marking up and activating dialogs
Start with the <dialog>
element:
<dialog> ... </dialog>
It’s hidden by default and, similar to <details>
, we can have it open
when the page loads, although it isn’t modal in this scenario since it does not contain interactive content because it doesn’t opened with showModal()
.
<dialog open> ... </dialog>
I can’t say that I’ve ever needed this functionality. Instead, you’ll likely want to reveal the dialog upon some kind of interaction, such as the click of a button — so here’s that button:
<button data-dialog="dialogA">Open dialogA</button>
Wait, why are we using data attributes? Well, because we might want to hand over an identifier that tells the JavaScript which dialog to open, enabling us to add the dialog functionality to all dialogs in one snippet, like this:
// Select and loop all elements with that data attribute
document.querySelectorAll("[data-dialog]").forEach(button => {
// Listen for interaction (click)
button.addEventListener("click", () => {
// Select the corresponding dialog
const dialog = document.querySelector(`#${ button.dataset.dialog }`);
// Open dialog
dialog.showModal();
// Close dialog
dialog.querySelector(".closeDialog").addEventListener("click", () => dialog.close());
});
});
Don’t forget to add a matching id
to the <dialog>
so it’s associated with the <button>
that shows it:
<dialog id="dialogA"> <!-- id and data-dialog = dialogA --> ... </dialog>
And, lastly, include the “close” button:
<dialog id="dialogA">
<button class="closeDialog">Close dialogA</button>
</dialog>
Note: <form method="dialog">
(that has a <button>
) or <button formmethod="dialog">
(wrapped in a <form>
) also closes the dialog.
How to prevent scrolling when the dialog is open
Prevent scrolling while the modal’s open, with one line of CSS:
body:has(dialog:modal) { overflow: hidden; }
Styling the dialog’s backdrop
And finally, we have the backdrop to reduce distraction from what’s underneath the top layer (this applies to modals only). Its styles can be overwritten, like this:
::backdrop {
background: hsl(0 0 0 / 90%);
backdrop-filter: blur(3px); /* A fun property just for backdrops! */
}
On that note, the <dialog>
itself comes with a border
, a background
, and some padding
, which you might want to reset. Actually, popovers behave the same way.
Dealing with non-modal dialogs
To implement a non-modal dialog, use:
show()
instead ofshowModal()
dialog[open]
(targets both) instead ofdialog:modal
Although, as I said before, the Popover API doesn’t require JavaScript, so for non-modal overlays I think it’s best to use that.
The Popover API (<element popover>
)
Use case: Non-modal overlays
Popups, basically. Suitable use cases include tooltips (or toggletips — it’s important to know the difference), onboarding walkthroughs, notifications, togglable navigations, and other non-modal overlays where you don’t want to lose access to the main document. Obviously these use cases are different to those of dialogs, but nonetheless popovers are extremely awesome. Functionally they’re just like just dialogs, but not modal and don’t require JavaScript.
Marking up popovers
To begin, the popover needs an id
as well as the popover
attribute with the manual
value (which means clicking outside of the popover doesn’t close it), the auto
value (clicking outside of the popover does close it), or no value (which means the same thing). To be semantic, the popover can be a <dialog>
.
<dialog id="tooltipA" popover> ... </dialog>
Next, add the popovertarget
attribute to the <button>
or <input type="button">
that we want to toggle the popover’s visibility, with a value matching the popover’s id
attribute (this is optional since clicking outside of the popover will close it anyway, unless popover
is set to manual
):
<dialog id="tooltipA" popover>
<button popovertarget="tooltipA">Hide tooltipA</button>
</dialog>
Place another one of those buttons in your main document, so that you can show the popover. That’s right, popovertarget
is actually a toggle (unless you specify otherwise with the popovertargetaction
attribute that accepts show
, hide
, or toggle
as its value — more on that later).
Styling popovers
By default, popovers are centered within the top layer (like dialogs), but you probably don’t want them there as they’re not modals, after all.
<main>
<button popovertarget="tooltipA">Show tooltipA</button>
</main>
<dialog id="tooltipA" popover>
<button popovertarget="tooltipA">Hide tooltipA</button>
</dialog>
You can easily pull them into a corner using fixed positioning, but for a tooltip-style popover you’d want it to be relative to the trigger that opens it. CSS Anchor Positioning makes this super easy:
main [popovertarget] {
anchor-name: --trigger;
}
[popover] {
margin: 0;
position-anchor: --trigger;
top: calc(anchor(bottom) + 10px);
justify-self: anchor-center;
}
/* This also works but isn’t needed
unless you’re using the display property
[popover]:popover-open {
...
}
*/
The problem though is that you have to name all of these anchors, which is fine for a tabbed component but overkill for a website with quite a few tooltips. Luckily, we can match an id
attribute on the button to an anchor
attribute on the popover
, which isn’t well-supported as of November 2024 but will do for this demo:
<main>
<!-- The id should match the anchor attribute -->
<button id="anchorA" popovertarget="tooltipA">Show tooltipA</button>
<button id="anchorB" popovertarget="tooltipB">Show tooltipB</button>
</main>
<dialog anchor="anchorA" id="tooltipA" popover>
<button popovertarget="tooltipA">Hide tooltipA</button>
</dialog>
<dialog anchor="anchorB" id="tooltipB" popover>
<button popovertarget="tooltipB">Hide tooltipB</button>
</dialog>
main [popovertarget] { anchor-name: --anchorA; } /* No longer needed */
[popover] {
margin: 0;
position-anchor: --anchorA; /* No longer needed */
top: calc(anchor(bottom) + 10px);
justify-self: anchor-center;
}
The next issue is that we expect tooltips to show on hover and this doesn’t do that, which means that we need to use JavaScript. While this seems complicated considering that we can create tooltips much more easily using ::before
/::after
/content:
, popovers allow HTML content (in which case our tooltips are actually toggletips by the way) whereas content:
only accepts text.
Adding JavaScript functionality
Which leads us to this…
Okay, so let’s take a look at what’s happening here. First, we’re using anchor
attributes to avoid writing a CSS block for each anchor element. Popovers are very HTML-focused, so let’s use anchor positioning in the same way. Secondly, we’re using JavaScript to show the popovers (showPopover()
) on mouseover
. And lastly, we’re using JavaScript to hide the popovers (hidePopover()
) on mouseout
, but not if they contain a link as obviously we want them to be clickable (in this scenario, we also don’t hide the button that hides the popover).
<main>
<button id="anchorLink" popovertarget="tooltipLink">Open tooltipLink</button>
<button id="anchorNoLink" popovertarget="tooltipNoLink">Open tooltipNoLink</button>
</main>
<dialog anchor="anchorLink" id="tooltipLink" popover>Has <a href="#">a link</a>, so we can’t hide it on mouseout
<button popovertarget="tooltipLink">Hide tooltipLink manually</button>
</dialog>
<dialog anchor="anchorNoLink" id="tooltipNoLink" popover>Doesn’t have a link, so it’s fine to hide it on mouseout automatically
<button popovertarget="tooltipNoLink">Hide tooltipNoLink</button>
</dialog>
[popover] {
margin: 0;
top: calc(anchor(bottom) + 10px);
justify-self: anchor-center;
/* No link? No button needed */
&:not(:has(a)) [popovertarget] {
display: none;
}
}
/* Select and loop all popover triggers */
document.querySelectorAll("main [popovertarget]").forEach((popovertarget) => {
/* Select the corresponding popover */
const popover = document.querySelector(`#${popovertarget.getAttribute("popovertarget")}`);
/* Show popover on trigger mouseover */
popovertarget.addEventListener("mouseover", () => {
popover.showPopover();
});
/* Hide popover on trigger mouseout, but not if it has a link */
if (popover.matches(":not(:has(a))")) {
popovertarget.addEventListener("mouseout", () => {
popover.hidePopover();
});
}
});
Implementing timed backdrops (and sequenced popovers)
At first, I was sure that popovers having backdrops was an oversight, the argument being that they shouldn’t obscure a focusable main document. But maybe it’s okay for a couple of seconds as long as we can resume what we were doing without being forced to close anything? At least, I think this works well for a set of onboarding tips:
<!-- Re-showing ‘A’ rolls the onboarding back to that step -->
<button popovertarget="onboardingTipA" popovertargetaction="show">Restart onboarding</button>
<!-- Hiding ‘A’ also hides subsequent tips as long as the popover attribute equates to auto -->
<button popovertarget="onboardingTipA" popovertargetaction="hide">Cancel onboarding</button>
<ul>
<li id="toolA">Tool A</li>
<li id="toolB">Tool B</li>
<li id="toolC">Another tool, “C”</li>
<li id="toolD">Another tool — let’s call this one “D”</li>
</ul>
<!-- onboardingTipA’s button triggers onboardingTipB -->
<dialog anchor="toolA" id="onboardingTipA" popover>
onboardingTipA <button popovertarget="onboardingTipB" popovertargetaction="show">Next tip</button>
</dialog>
<!-- onboardingTipB’s button triggers onboardingTipC -->
<dialog anchor="toolB" id="onboardingTipB" popover>
onboardingTipB <button popovertarget="onboardingTipC" popovertargetaction="show">Next tip</button>
</dialog>
<!-- onboardingTipC’s button triggers onboardingTipD -->
<dialog anchor="toolC" id="onboardingTipC" popover>
onboardingTipC <button popovertarget="onboardingTipD" popovertargetaction="show">Next tip</button>
</dialog>
<!-- onboardingTipD’s button hides onboardingTipA, which in-turn hides all tips -->
<dialog anchor="toolD" id="onboardingTipD" popover>
onboardingTipD <button popovertarget="onboardingTipA" popovertargetaction="hide">Finish onboarding</button>
</dialog>
::backdrop {
animation: 2s fadeInOut;
}
[popover] {
margin: 0;
align-self: anchor-center;
left: calc(anchor(right) + 10px);
}
/*
After users have had a couple of
seconds to breathe, start the onboarding
*/
setTimeout(() => {
document.querySelector("#onboardingTipA").showPopover();
}, 2000);
Again, let’s unpack. Firstly, setTimeout()
shows the first onboarding tip after two seconds. Secondly, a simple fade-in-fade-out background animation runs on the backdrop and all subsequent backdrops. The main document isn’t made inert and the backdrop doesn’t persist, so attention is diverted to the onboarding tips while not feeling invasive.
Thirdly, each popover has a button that triggers the next onboarding tip, which triggers another, and so on, chaining them to create a fully HTML onboarding flow. Typically, showing a popover closes other popovers, but this doesn’t appear to be the case if it’s triggered from within another popover. Also, re-showing a visible popover rolls the onboarding back to that step, and, hiding a popover hides it and all subsequent popovers — although that only appears to work when popover
equates to auto
. I don’t fully understand it but it’s enabled me to create “restart onboarding” and “cancel onboarding” buttons.
With just HTML. And you can cycle through the tips using esc
and return
.
Creating modal popovers
Hear me out. If you like the HTML-ness of popover
but the semantic value of <dialog>
, this JavaScript one-liner can make the main document inert, therefore making your popovers modal:
document.querySelectorAll("dialog[popover]").forEach(dialog => dialog.addEventListener("toggle", () => document.body.toggleAttribute("inert")));
However, the popovers must come after the main document; otherwise they’ll also become inert. Personally, this is what I’m doing for modals anyway, as they aren’t a part of the page’s content.
<body>
<!-- All of this will become inert -->
</body>
<!-- Therefore, the modals must come after -->
<dialog popover> ... </dialog>
Aaaand… breathe
Yeah, that was a lot. But…I think it’s important to look at all of these APIs together now that they’re starting to mature, in order to really understand what they can, can’t, should, and shouldn’t be used for. As a parting gift, I’ll leave you with a transition-enabled version of each API:
- Sliding disclosures
- Popping dialog (with fading backdrop)
- Sliding popover (hamburger nav, because why not?)
The Different (and Modern) Ways to Toggle Content