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.
<details>
and <summary>
)
Disclosures (Use case: Accessibly summarizing content while making the content details togglable independently or as an accordion.
Update 12/12/2024: A new article on the Chrome blog has demonstrated a couple more interesting ways to use <details>
, including an animated gallery and a partially-open <details>
 (optionally animated using calc-size
).
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>
.
<dialog>
)
The Dialog element (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.
<element popover>
)
The Popover API (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?)
Aaaand⦠itâs fully animatable, as of 2024.
I’m not clear on how to animate
details
andsummary
disclosure blocks. Is that possible?This Kevin Powell video has you covered:
Hey Chris, there are three links (demos) right at the end of the article that should have you covered â let us know how you get on
You mentioned that the Anchor attribute isn’t well supported, but Anchor Positioning in general isn’t well supported yet either? (Safari and Firefox both don’t support it yet).
Correct, but
You’re right, and hopefully that changes soon.
(Sorry, not sure what happened to my other comment.)
Someone needs to tell validator.w3.org, because at the moment I’m getting:
Error: Attribute name for element details not allowed at this location .
Oh, validator is waaaay behind
I forgot all about it, to be honest.