The Different (and Modern) Ways To Toggle Content | CSS-Tricks


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 (

and

), 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 (

and

)

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

and

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

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

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

...

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

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

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

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:

...
...
...

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,

is basically a , meaning that its content is announced by screen readers when in focus. If there isn’t a

or

isn’t a direct child of

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,

has the role of button, so whatever’s invalid inside a

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

so it’s associated with the

Note: (that has a

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.

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 [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).

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:






  • 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 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

, 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… 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:



Read more