Skip to main content
Back to blog
CSSHTMLAccordionFrontendPerformance

A smooth accordion with zero JavaScript

Everyone reaches for React state and a height-measuring hook to build an accordion. The browser has shipped a native one for years — and as of 2024 it animates open and closed with pure CSS. Here's the full technique: details[name], ::details-content, interpolate-size, and the one property that makes closing animate too.

Published July 28, 20269 min read

An accordion is one of the most re-implemented widgets on the web. The usual recipe: a piece of React state for what's open, an onClick to toggle it, and some CSS (or a height-measuring hook) to animate the reveal. It works, but it turns a piece of static content into a client component that ships and hydrates JavaScript — just to open and close.

The browser has had a native accordion element for years: <details>. Its one historical weakness — you couldn't animate it — is gone as of 2024. This article builds a fully animated, exclusive accordion with a rotating chevron and zero JavaScript: no state, no hooks, no hydration.

The accordion everyone ships

Here's the pattern in its most common form — React state for the open item, a click handler to toggle it, and the grid-template-rows: 0fr → 1fr trick to animate the height without measuring anything:

Accordion.tsx
"use client"; // ← the whole component is now a client bundle

import { useState } from "react";

export function Accordion({ items }: { items: Item[] }) {
  const [openId, setOpenId] = useState<string | null>(null);

  return (
    <div>
      {items.map((item) => {
        const isOpen = openId === item.id;
        return (
          <div key={item.id}>
            <button onClick={() => setOpenId(isOpen ? null : item.id)}>
              {item.title}
            </button>
            {/* grid 0fr → 1fr is the CSS trick that animates the height */}
            <div className="body" data-open={isOpen}>
              <div>{item.content}</div>
            </div>
          </div>
        );
      })}
    </div>
  );
}
.body {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 300ms;
}
.body[data-open="true"] { grid-template-rows: 1fr; }
.body > div { overflow: hidden; }

It's fine, and the grid trick is genuinely clever. But note what it costs: the "use client" at the top. This component now downloads, parses, and hydrates on every page it appears on — and it does all that to manage a single boolean. For a presentational FAQ that never needed to be interactive in the React sense, that's pure overhead.

The native element you forgot

<details> and <summary> are a built-in disclosure widget. The browser handles the open/close state, click and keyboard interaction, and accessibility — for free:

<details>
  <summary>What does it cost?</summary>
  <p>Nothing — it's a native browser element.</p>
</details>

That is a working accordion with no JavaScript and no CSS. Click (or press Enter on) the summary and it toggles. The catch, for years, was that it toggled instantly — there was no way to animate the reveal, which is exactly why teams reached for the JavaScript version instead.

Why it couldn't animate — until recently

The reason is subtle. When a <details> is closed, the browser doesn't just visually hide its content — it takes the content out of rendering entirely (a content-visibility-style hide). You can't transition to or from "not rendered," and for a long time you also couldn't transition height to or from auto. So there was nothing to animate between.

People tried the same grid trick that the JavaScript version uses, moving it onto the [open] attribute:

/* Looks like it should work — but only animates OPENING. */
details > .body {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 300ms;
}
details[open] > .body { grid-template-rows: 1fr; }
/* On close, the browser hides the content the instant [open] is gone,
   so the collapse never animates — it just snaps shut. */

It looks right, and it even animates the opening. But it fails on closing: the moment the [open] attribute is removed, the browser hides the content immediately, so the collapse never gets a chance to animate — it just snaps shut. Half a solution is what kept everyone on JavaScript.

The fix: ::details-content + interpolate-size

Three CSS features that landed in 2024 solve it together. The key is a new pseudo-element, ::details-content, which targets exactly the collapsible part of a <details> — so you can style and animate it directly:

:root {
  /* lets height/block-size transition to and from the 'auto' keyword */
  interpolate-size: allow-keywords;
}

details::details-content {
  block-size: 0;
  overflow: clip;
  transition:
    block-size 300ms ease,
    /* keeps the content rendered while it collapses, instead of vanishing */
    content-visibility 300ms allow-discrete;
}

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

Read as one unit, that's three things cooperating:

  • ::details-content gives you a handle on the hidden content region, which you previously couldn't select at all.
  • interpolate-size: allow-keywords permits animating block-size between 0 and the intrinsic auto height — the thing CSS historically refused to do.
  • transition-behavior: allow-discrete (via listing content-visibility in the transition) keeps the content rendered through the collapse, so closing animates instead of snapping.

Together they animate both directions — open and close — with the height easing smoothly to and from the content's natural size. No measuring, no hook, no state.

Exclusive accordion, with one attribute

Most accordions want "only one open at a time." In the JavaScript version that's what the openId state is for. Natively, it's a single attribute: give every <details> in the group the same name, and the browser enforces exclusivity — opening one closes the rest:

<!-- Same name = one exclusive group. Opening one closes the others. -->
<details name="faq"><summary>First</summary><p>…</p></details>
<details name="faq"><summary>Second</summary><p>…</p></details>
<details name="faq"><summary>Third</summary><p>…</p></details>

One caveat worth knowing: name defines an exclusive group across the whole page, so if you render two independent accordions, give each its own unique name — otherwise opening an item in one will close an item in the other.

The chevron, with no JavaScript

The last piece people reach for JS for is the little arrow that flips when the section opens. It's just a transform keyed off the [open] attribute — the browser sets that attribute, so the rotation is pure CSS. Also hide the default disclosure triangle while you're here:

summary {
  list-style: none;            /* remove the default triangle marker */
  cursor: pointer;
}
summary::-webkit-details-marker { display: none; }  /* Safari */

summary .chevron {
  transition: transform 200ms ease;
}
details[open] summary .chevron {
  transform: rotate(180deg);   /* flip on open — pure CSS, no JS */
}

Putting it together

Here is the whole thing — an exclusive, animated accordion item with a rotating chevron and a gap between question and answer:

<details name="faq" class="item">
  <summary>
    <h3>How does it work?</h3>
    <svg class="chevron" aria-hidden="true"><!-- ↓ --></svg>
  </summary>
  <div class="answer">
    <p>Native open/close, exclusive grouping, smooth animation — zero JS.</p>
  </div>
</details>
:root { interpolate-size: allow-keywords; }

.item::details-content {
  block-size: 0;
  overflow: clip;
  transition:
    block-size 300ms ease,
    content-visibility 300ms allow-discrete;
}
.item[open]::details-content { block-size: auto; }

.item[open] summary .chevron { transform: rotate(180deg); }
.answer { padding-top: 12px; }   /* gap between question and answer */

That is the complete feature. Open/close, keyboard support, accessibility, exclusivity, a smooth two-way animation, and a flipping chevron — and it ships not one byte of JavaScript. It renders as static HTML on the server and needs nothing on the client to work.

Browser support

::details-content, interpolate-size, and allow-discrete are recent (Chromium 129–131, Safari 18.x, Firefox following). That sounds risky, but the degradation is the best kind — you don't need @supports at all:

/* No @supports needed: browsers that don't know ::details-content simply
   ignore these rules. The <details> still opens and closes — just instantly.
   The animation is a progressive enhancement, never a requirement. */

On a browser that doesn't understand these rules, the <details> still opens and closes correctly; it simply does so instantly, without the animation. The animation is a progressive enhancement layered on top of an element that already works everywhere — so there's no fallback to write and nothing to break.

The behavior — open, close, keyboard, accessibility, exclusivity — is native and works everywhere. The animation is pure CSS on top. If a browser is too old for the animation, the accordion still works; it just doesn't ease.

CSS vs JavaScript, honestly

ConcernJS accordion (state + hook)Native <code>&lt;details&gt;</code> + CSS
Open / closeReact state + onClickBuilt into the browser
JavaScript shippedYes (client component, hydrates)None
Smooth animationYes (grid / measured height)Yes (::details-content)
Only-one-openManual state (openId)name attribute
Keyboard + a11yYou wire it (or a library)Native
Works before hydration / with JS offNoYes
Chevron flipClass toggled by JSCSS on [open]

JavaScript still wins when the panel's content is genuinely dynamic — loaded on demand, driven by app state, or when opening must trigger side effects you control in JS. But for the overwhelmingly common case — a presentational FAQ or disclosure list — the native element matches the JS version feature-for-feature, with none of the bundle.

Takeaways

  • The JavaScript accordion everyone writes exists mostly because native <details> couldn't animate — a limitation that's now gone.
  • ::details-content + interpolate-size: allow-keywords animate the height between 0 and auto.
  • The trick that makes closing animate (not just opening) is transition-behavior: allow-discrete on content-visibility.
  • <details name="…"> gives you an exclusive "one open at a time" group with zero JavaScript — use a unique name per accordion.
  • The chevron flip is a CSS transform keyed off the [open] attribute.
  • It degrades gracefully with no @supports: old browsers open/close instantly, and the animation is a pure enhancement.

None of these pieces are exotic — <details> is decades old, and the animation features are boring, well-specified CSS. The shift is realizing you no longer have to reach for React state to get a polished accordion. Delete the client component, ship static HTML, and let the browser do what it has quietly been able to do all along.