Type something to search...
Building a Pure CSS Accordion with details and summary

Building a Pure CSS Accordion with details and summary

Accordions are everywhere: FAQ pages, product specs, settings panels, mobile navigation. For a long time, building one meant writing a click handler, toggling a class, managing aria-expanded, and hoping you remembered keyboard support. HTML has had a native disclosure widget for years, the <details> element with its <summary> child, and it has quietly become good enough to replace most JavaScript accordions.

In this guide we'll start with the bare element, style it into something presentable, replace the default marker with a custom icon, make a group of panels behave as an exclusive accordion, and finally animate the open and close transition. Where a feature is still rolling out across browsers, I'll say so and show a fallback.

Why Use <details> Instead of a Custom Component?

Before we style anything, it's worth understanding what the browser gives you for free:

  • Keyboard support : The <summary> is focusable and toggles with Enter or Space. No tabindex or key handlers needed.
  • Screen reader semantics : Assistive technology announces the summary as a button-like control along with its expanded or collapsed state.
  • State without JavaScript : The element has an open attribute that the browser adds and removes. You can style against it with details[open].
  • Find-in-page : In Chromium-based browsers, and increasingly elsewhere, searching the page with Ctrl+F or Cmd+F can find text inside a closed <details> element and open it automatically. A custom accordion that hides content with display: none can't do that.
  • Works before scripts load : The accordion functions the moment the HTML arrives, even if your JavaScript bundle is slow or fails.

The main trade-off is that you have less control over the exact structure. The summary must be the first child, and the rest of the content is treated as the collapsible body.

The Bare Minimum

Here's the unstyled starting point:

<details>
  <summary>What is your refund policy?</summary>
  <p>
    You can request a full refund within 30 days of purchase. Contact support
    with your order number.
  </p>
</details>

That's already a working disclosure widget. The browser draws a small triangle marker, hides the paragraph, and toggles it when the summary is clicked. Add the open attribute to have it start expanded:

<details open>
  <summary>Shipping times</summary>
  <p>Orders ship within two business days.</p>
</details>

Building the Accordion Markup

For a real accordion, wrap a group of <details> elements in a container and give the body its own wrapper. The wrapper makes padding and animation much easier later.

<div class="accordion">
  <details class="accordion__item">
    <summary class="accordion__trigger">What is your refund policy?</summary>
    <div class="accordion__panel">
      <p>
        You can request a full refund within 30 days of purchase. Contact
        support with your order number and we'll process it within five business
        days.
      </p>
    </div>
  </details>

  <details class="accordion__item">
    <summary class="accordion__trigger">Do you ship internationally?</summary>
    <div class="accordion__panel">
      <p>
        Yes. We ship to over 40 countries. Duties and taxes are calculated at
        checkout.
      </p>
    </div>
  </details>

  <details class="accordion__item">
    <summary class="accordion__trigger">
      Can I change my order after placing it?
    </summary>
    <div class="accordion__panel">
      <p>
        Orders can be edited for one hour after purchase from your account
        dashboard.
      </p>
    </div>
  </details>
</div>

Avoid putting headings or interactive elements such as links or buttons inside <summary>. The summary already behaves like a button, and nesting interactive content inside it causes confusing behavior for keyboard and screen reader users. If you need a heading for document structure, it's acceptable to wrap the summary text in a heading element, but test it with a screen reader, because some combinations announce it awkwardly.

Styling the Container and Items

Let's give the accordion a clean, card-like appearance:

.accordion {
  max-width: 42rem;
  margin-inline: auto;
  border: 1px solid #e2e8f0;
  border-radius: 12px;
  overflow: hidden;
  background: #fff;
}

.accordion__item + .accordion__item {
  border-top: 1px solid #e2e8f0;
}

.accordion__trigger {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  padding: 1rem 1.25rem;
  font-weight: 600;
  cursor: pointer;
  list-style: none;
}

.accordion__panel {
  padding: 0 1.25rem 1.25rem;
  color: #475569;
  line-height: 1.6;
}

.accordion__panel > :first-child {
  margin-top: 0;
}

.accordion__panel > :last-child {
  margin-bottom: 0;
}

The adjacent sibling selector (+) draws a divider between items without adding a stray border to the first or last one. Setting overflow: hidden on the container keeps the item backgrounds from poking out past the rounded corners.

Replacing the Default Marker

The default triangle is rendered through the ::marker pseudo-element, because <summary> has display: list-item by default. Changing the summary to display: flex, as we did above, already removes the marker in current browsers, and list-style: none removes it for good measure. Older Safari versions used a nonstandard pseudo-element instead, so it's worth including a line for it as well:

.accordion__trigger::-webkit-details-marker {
  display: none;
}

Now add a custom icon. A plus sign that rotates into an X, or a chevron that flips, are both common. Here's a chevron drawn purely with borders:

.accordion__trigger::after {
  content: "";
  flex-shrink: 0;
  width: 0.55rem;
  height: 0.55rem;
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: rotate(45deg) translateY(-25%);
  transition: transform 0.2s ease;
}

.accordion__item[open] > .accordion__trigger::after {
  transform: rotate(-135deg) translateY(-25%);
}

The [open] attribute selector is the heart of every <details> styling trick. The browser toggles it for you, so anything you'd normally do with an .is-open class works here.

A plus/minus icon instead

If you prefer a plus that turns into a minus, use two pseudo-elements. Since ::after already exists on the summary, draw both bars with a background gradient:

.accordion__trigger::after {
  content: "";
  flex-shrink: 0;
  width: 14px;
  height: 14px;
  background:
    linear-gradient(currentColor, currentColor) center / 100% 2px no-repeat,
    linear-gradient(currentColor, currentColor) center / 2px 100% no-repeat;
  transition: background-size 0.2s ease;
}

.accordion__item[open] > .accordion__trigger::after {
  background-size:
    100% 2px,
    2px 0;
}

When the item opens, the vertical bar's height animates to zero, leaving only the horizontal bar.

Hover, Focus, and Open States

Interactive states make the accordion feel responsive and help keyboard users see where they are:

.accordion__trigger:hover {
  background: #f8fafc;
}

.accordion__trigger:focus-visible {
  outline: 2px solid #2563eb;
  outline-offset: -2px;
}

.accordion__item[open] > .accordion__trigger {
  color: #1d4ed8;
}

Use :focus-visible rather than :focus so the outline appears for keyboard navigation but not after every mouse click. The negative outline-offset draws the ring inside the item, which matters because the container has overflow: hidden and would otherwise clip it.

Making It Exclusive with the name Attribute

A classic accordion allows only one panel open at a time. This used to require JavaScript, but HTML now supports it directly: give every <details> in the group the same name attribute.

<div class="accordion">
  <details class="accordion__item" name="faq" open>
    <summary class="accordion__trigger">What is your refund policy?</summary>
    <div class="accordion__panel">...</div>
  </details>

  <details class="accordion__item" name="faq">
    <summary class="accordion__trigger">Do you ship internationally?</summary>
    <div class="accordion__panel">...</div>
  </details>
</div>

When one item opens, the browser closes any other open item that shares the same name. It works the same way radio buttons share a name. Exclusive <details> is supported in all current major browsers. In a browser that doesn't recognize the attribute, the items simply behave independently, which is a perfectly acceptable fallback.

A word of caution: exclusive accordions aren't always the better choice. If users might want to compare two answers, forcing one closed when another opens is frustrating. Use name for navigation-style accordions and leave FAQ lists independent unless you have a reason not to.

Animating the Open and Close

This is where <details> has historically been tricky. When the element toggles, the browser switches the content from hidden to shown instantly, and you can't transition height from 0 to auto in older CSS. Newer features fix this, but support is still uneven, so treat animation as a progressive enhancement.

The modern approach: ::details-content and interpolate-size

Two newer additions work together:

  • ::details-content is a pseudo-element that targets the collapsible part of <details> (everything except the summary). You can finally style and transition it directly.
  • interpolate-size: allow-keywords lets the browser animate between a fixed length and intrinsic sizes like auto.
:root {
  interpolate-size: allow-keywords;
}

.accordion__item::details-content {
  block-size: 0;
  overflow: hidden;
  transition:
    block-size 0.3s ease,
    content-visibility 0.3s allow-discrete;
}

.accordion__item[open]::details-content {
  block-size: auto;
}

The content-visibility transition with allow-discrete is important. Browsers hide closed <details> content with content-visibility: hidden, and without the discrete transition that value flips immediately on close, so the content vanishes before the height animation can play.

As of this writing, ::details-content and interpolate-size are available in Chromium-based browsers, and ::details-content has also landed in recent Safari. Firefox support is still in progress. Check caniuse before relying on either. Browsers that don't understand them ignore the rules and fall back to the instant toggle, so nothing breaks.

To keep things tidy, wrap the enhancement in a feature query:

@supports selector(::details-content) {
  .accordion__item::details-content {
    block-size: 0;
    overflow: hidden;
    transition:
      block-size 0.3s ease,
      content-visibility 0.3s allow-discrete;
  }

  .accordion__item[open]::details-content {
    block-size: auto;
  }
}

In a browser that supports ::details-content but not interpolate-size, the panel still opens and closes, just without the smooth height change.

A lighter alternative: fade the panel in

If you only want a subtle effect that works everywhere, animate the panel when it appears. Because the content is displayed fresh each time [open] is added, a keyframe animation replays on every open:

.accordion__item[open] .accordion__panel {
  animation: panel-in 0.25s ease-out;
}

@keyframes panel-in {
  from {
    opacity: 0;
    transform: translateY(-6px);
  }
}

This animates opening only. Closing is still instant, but for many designs that's fine, and it needs no modern features.

Respecting reduced motion

Wrap motion in a preference check so people who've asked for less animation get an instant toggle:

@media (prefers-reduced-motion: reduce) {
  .accordion__item::details-content {
    transition: none;
  }

  .accordion__item[open] .accordion__panel {
    animation: none;
  }

  .accordion__trigger::after {
    transition: none;
  }
}

Styling Nested Content

Accordion panels often contain lists, code, or even nested accordions. A few rules help keep everything consistent:

.accordion__panel ul {
  padding-left: 1.25rem;
}

.accordion__panel details {
  margin-top: 0.75rem;
  border: 1px solid #e2e8f0;
  border-radius: 8px;
}

.accordion__panel details > summary {
  padding: 0.6rem 0.9rem;
  font-weight: 500;
  cursor: pointer;
}

Because nested <details> elements each keep their own open state, you should always use the child combinator (>) when targeting the summary and icon. Otherwise, opening an outer item could accidentally rotate the icon of every nested item as well.

Dark Mode Support

Custom properties make theming straightforward. Define the colors once and swap them in a media query:

.accordion {
  --acc-bg: #ffffff;
  --acc-border: #e2e8f0;
  --acc-text: #0f172a;
  --acc-muted: #475569;
  --acc-hover: #f8fafc;

  background: var(--acc-bg);
  border-color: var(--acc-border);
  color: var(--acc-text);
}

@media (prefers-color-scheme: dark) {
  .accordion {
    --acc-bg: #111827;
    --acc-border: #334155;
    --acc-text: #f1f5f9;
    --acc-muted: #94a3b8;
    --acc-hover: #1f2937;
  }
}

Then reference var(--acc-border), var(--acc-hover), and var(--acc-muted) in the item, hover, and panel rules instead of hardcoded hex values.

Opening Items from a Link

Sometimes you want a URL like /faq#shipping to open the matching item. Pure CSS can highlight the target with :target, but it can't set the open attribute. This is one of the few cases where a small script helps:

function openFromHash() {
  const target = document.getElementById(location.hash.slice(1));
  const details = target?.closest("details");
  if (details) details.open = true;
}

window.addEventListener("hashchange", openFromHash);
openFromHash();

Put the id on the <details> element itself. Everything else about the accordion stays JavaScript-free.

Common Pitfalls

  1. Styling summary as display: block and wondering where the marker went : Changing the display type removes the list-item behavior, and with it the default marker. This is usually what you want, but be aware that it's happening.
  2. Padding on <details> instead of the panel : Padding on the <details> element also applies around the summary, which makes the clickable area look smaller than it is. Put padding on the summary and on an inner panel wrapper.
  3. Trying to transition height on the panel wrapper : The wrapper is hidden entirely when the item is closed, so there's no starting height to animate from. Animate ::details-content instead, or use the keyframe fade.
  4. Interactive elements inside the summary : A link inside the summary competes with the summary's own toggle. Keep the summary text-only, with optional decorative icons.
  5. Forgetting focus styles : When you restyle the summary, make sure the focus ring is still visible, especially with overflow: hidden on the parent.

Browser Support Summary

  • <details> and <summary> : supported in every modern browser.
  • name for exclusive accordions : supported in all current major browsers; ignored gracefully elsewhere.
  • ::marker styling on summary : broadly supported; include ::-webkit-details-marker for older Safari.
  • ::details-content : Chromium-based browsers and recent Safari; Firefox support pending at the time of writing.
  • interpolate-size : Chromium-based browsers only at the time of writing.

Conclusion

The <details> and <summary> pair is one of the best examples of the platform catching up with what developers used to build by hand. You get keyboard support, accessible semantics, find-in-page, and open-state styling out of the box. With the name attribute, you get exclusive accordions without a single event listener, and with ::details-content you can add smooth animation where browsers support it.

Start with the semantic markup, style the summary and panel, replace the marker with your own icon, and layer on animation as an enhancement. The result is an accordion that's lighter, more robust, and more accessible than most JavaScript versions, and it keeps working even when your scripts don't.

Tags :
Share :

Related Posts

A Complete Guide to CSS Container Queries

A Complete Guide to CSS Container Queries

For more than a decade, responsive design meant one thing: media queries. You asked the browser how wide the viewport was and adjusted your layout ac

Continue Reading
A Comprehensive Guide to Installing Next.js

A Comprehensive Guide to Installing Next.js

Next.js has emerged as a powerful framework for building React applications, offering features like server-side rendering, static site generation, an

Continue Reading
Advanced CSS with clamp(), min(), and max(): Simplifying Dynamic Styling

Advanced CSS with clamp(), min(), and max(): Simplifying Dynamic Styling

CSS has evolved significantly, and modern tools like clamp(), min(), and max() are powerful game-changers in dynamic styling. If you’ve struggl

Continue Reading