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.

CodePen Embed FallbackGoing 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 disclosuresWhat you’re looking for is this:

```

Content summary (always visible) Content (visibility is toggled when summary is clicked on)

`` 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:

```

<::marker>Content summary (always visible) <::details-content> Content (visibility is toggled when summary is clicked on)

`` To have the disclosure open by default, give

theopen` attribute, which is what happens behind the scenes when disclosures are opened anyway.

```

...

`` Styling disclosuresLet’s be real: you probably just want to lose that annoying marker. Well, you can do that by setting thedisplayproperty of

to anything butlist-item`:

summary { display: block; /* Or anything else that isn't list-item */} CodePen Embed FallbackAlternatively, 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.

```

Content summary Content

```

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; } } CodePen Embed FallbackCreating an accordion with multiple disclosuresCodePen Embed FallbackTo 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">):

```

Prequels
  • Episode I: The Phantom Menace
  • Episode II: Attack of the Clones
  • Episode III: Revenge of the Sith
Originals
  • Episode IV: A New Hope
  • Episode V: The Empire Strikes Back
  • Episode VI: Return of the Jedi
Sequels
  • Episode VII: The Force Awakens
  • Episode VIII: The Last Jedi
  • Episode IX: The Rise of Skywalker

``` Using a wrapper, we can even turn these into horizontal tabs:

CodePen Embed Fallback ```

...
...
...

```

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); } }} CodePen Embed FallbackIf 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 functionalityWant to add some JavaScript functionality?

// Optional: select and loop multiple disclosuresdocument.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 disclosuresDisclosures 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

CodePen Embed FallbackNow 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 an aria-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 attributedocument.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 openPrevent scrolling while the modal’s open, with one line of CSS:

body:has(dialog:modal) { overflow: hidden; } Styling the dialog’s backdropAnd 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 dialogsTo implement a non-modal dialog, use:

  • show() instead of showModal()
  • dialog[open] (targets both) instead of dialog: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

CodePen Embed FallbackPopups, 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 popoversTo 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 popoversCodePen Embed FallbackBy 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.

```

``` 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 neededunless 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:

CodePen Embed Fallback ```

```

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 functionalityWhich leads us to this…

CodePen Embed FallbackOkay, 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).

```

Has a link, so we can’t hide it on mouseout Doesn’t have a link, so it’s fine to hide it on mouseout automatically ```

[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:

CodePen Embed Fallback ```

  • Tool A
  • Tool B
  • Another tool, “C”
  • Another tool — let’s call this one “D”

onboardingTipA onboardingTipB onboardingTipC onboardingTipD ```

::backdrop { animation: 2s fadeInOut;}[popover] { margin: 0; align-self: anchor-center; left: calc(anchor(right) + 10px);}

/*After users have had a couple ofseconds 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 popoversHear 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.

```

... ``` Aaaand… breatheYeah, 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 originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.