CSS-Only Accordions and Disclosure Widgets with details and summary
Accordions have been a JavaScript component for as long as component libraries have existed, largely for one reason: nobody could animate a panel to its natural height without measuring it in script first. That constraint is gone. The <details> element gives you the toggle, its name attribute gives you exclusivity, ::details-content gives you a box to animate, and intrinsic-size interpolation gives you the auto height. This page, part of the Keyframe Animation Patterns section, assembles all four into a production accordion with no script and no ARIA of your own.
The narrow scenario: a stacked set of FAQ or settings panels where opening one closes the others, each panel slides open to whatever height its content needs, and the whole thing is keyboard and screen-reader correct out of the box.
Why the native element beats the checkbox hack
The classic CSS-only disclosure is a visually hidden checkbox, a <label> styled as a header, and a sibling panel revealed with :checked ~ .panel. It works, and for a decade it was the only option. It is also the wrong control. Assistive technology announces a checkbox — a control for selecting a value — where the user is actually expanding a region. Fixing that means adding role="button", aria-expanded, aria-controls, and a keyboard handler, at which point the "no JavaScript" claim is gone.
<details> and <summary> carry the correct semantics natively. The summary is focusable, responds to Enter and Space, exposes a disclosure widget with a managed expanded state, and the panel participates in find-in-page: searching for text inside a closed panel opens it. None of that is code you write or maintain. The remaining objection used to be visual — the element could not be animated — and that is precisely what the CSS below solves. The one case where a hand-rolled widget still wins is when the header must contain its own interactive controls, since interactive content inside <summary> is not reliably operable; put those controls inside the panel instead.
Anatomy of an animated details element
The diagram shows what changes between the closed and open states. The summary box is always rendered; everything else lives inside a generated ::details-content box whose block size is what actually animates.
A complete working implementation
Open a section in the demo, then open another one — the first closes itself, and each panel eases to the exact height its own text needs. Tab through it as well; the focus ring lands on the summary and Enter toggles it.
<div class="acc">
<details class="acc__item" name="faq">
<summary class="acc__summary">Does this need JavaScript?</summary>
<div class="acc__body">
<p>No. The <code>details</code> element toggles itself, and the open height is animated in CSS.</p>
</div>
</details>
<details class="acc__item" name="faq">
<summary class="acc__summary">What makes only one open at a time?</summary>
<div class="acc__body">
<p>Both elements share <code>name="faq"</code>, so the browser closes the other one for you.</p>
</div>
</details>
<details class="acc__item" name="faq">
<summary class="acc__summary">Is it keyboard accessible?</summary>
<div class="acc__body">
<p>Yes — the summary is focusable and toggles with Enter or Space with no extra ARIA.</p>
</div>
</details>
</div>
/* Opt the subtree into interpolating to and from the auto keyword. */
:root { interpolate-size: allow-keywords; }
.acc { border: 1px solid currentColor; border-radius: 10px; overflow: hidden; }
.acc__item + .acc__item { border-top: 1px solid currentColor; }
/* list-style: none removes the default disclosure triangle in most engines;
the ::-webkit-details-marker rule covers older WebKit builds. */
.acc__summary {
list-style: none;
cursor: pointer;
padding: .8rem 1rem;
font-weight: 600;
}
.acc__summary::-webkit-details-marker { display: none; }
.acc__summary::before {
content: "\25B8";
display: inline-block;
margin-right: .55rem;
transition: rotate 220ms ease;
}
.acc__item[open] > .acc__summary::before { rotate: 90deg; }
.acc__summary:focus-visible { outline: 2px solid currentColor; outline-offset: -2px; }
/* The generated wrapper around everything after the summary. Animating it
avoids wrapping the panel in an extra div purely to have a box to size. */
.acc__item::details-content {
block-size: 0;
overflow: hidden;
opacity: 0;
transition:
block-size 260ms ease,
opacity 200ms ease,
content-visibility 260ms allow-discrete;
}
.acc__item[open]::details-content {
block-size: auto;
opacity: 1;
}
.acc__body { padding: 0 1rem 1rem; }
.acc__body p { margin: 0; }
@media (prefers-reduced-motion: reduce) {
.acc__item::details-content,
.acc__summary::before { transition: none; }
}
The content-visibility entry in the transition list matters more than it looks. A closed ::details-content box is skipped for rendering, and that is a discrete change — without allow-discrete holding it visible for the duration, the panel's contents would vanish on the first frame of the collapse and you would animate an empty box. The same keyword does the same job for display, as covered in transitioning display with allow-discrete.
The key technique: making auto an interpolatable value
height: auto has never been animatable because auto is a keyword, not a length — there is no arithmetic that produces a value one third of the way between 0px and auto. Two additions solve that. calc-size() is a sizing function that accepts an intrinsic keyword as its basis and lets you compute against it, which by itself gives the engine a numeric handle on the keyword. interpolate-size: allow-keywords is the opt-in that turns the handle on implicitly: set it on an ancestor and every auto, min-content, max-content, and fit-content value in that subtree becomes interpolatable, so an ordinary transition: block-size 260ms between 0 and auto produces real intermediate heights.
The opt-in exists because animating to auto is a behaviour change for pages written before it was possible — sheets that set transition: all on an element that also changes from a fixed height to auto would suddenly start animating something that used to snap. Scoping it to :root in a new codebase is fine; adding it to an existing one is worth testing. If you would rather not opt in globally, write the open state as block-size: calc-size(auto, size) on the specific rule instead and leave the rest of the document alone.
Exclusivity comes from a different mechanism entirely and needs no CSS: give every <details> in a group the same name value and the browser enforces one-open-at-a-time itself, closing the previously open sibling and firing its toggle event. Grouped elements do not have to be siblings in the DOM, which is convenient when the accordion rows are wrapped individually for layout.
Variation: a disclosure that stays open on large screens
A common requirement is a filter panel that behaves as a collapsible disclosure on narrow layouts but is permanently open when there is room. Because the element responds to its own container rather than the viewport, this composes naturally with container query units and container-based conditions.
.filters { container-type: inline-size; }
@container (min-width: 40rem) {
/* Force the panel open and hide the toggle affordance entirely. */
.filters .acc__item::details-content {
block-size: auto;
opacity: 1;
content-visibility: visible;
transition: none;
}
.filters .acc__summary { cursor: default; }
.filters .acc__summary::before { display: none; }
}
This is a presentational override, not a semantic one — the element still reports itself as a disclosure. If the wide layout genuinely has no collapsed state, prefer rendering a plain heading and section for that breakpoint, and keep <details> for the layout where collapsing is real.
Browser support note
<details> and <summary> themselves have been universally supported for many years, so the accordion's behaviour and semantics are never at risk. The newer pieces arrived separately, and they are not equally available. ::details-content is in every current engine — Chrome and Edge 131+, Firefox 143+, Safari 18.4+. interpolate-size and calc-size(), which are what make the height animate to auto, remain Chromium-only at Chrome and Edge 129+; neither Firefox nor Safari has shipped them. Treat the height animation as a Chromium-only enhancement rather than a baseline behaviour. Every one of these is additive: an engine that does not know ::details-content drops the rule as an unknown selector, one that does not know interpolate-size drops the declaration, and one that does not know name simply lets both panels stay open. The accordion opens, closes, and reads correctly in all of those cases, which makes an @supports guard optional; add one only if the un-animated state needs different padding.
FAQ
Why can I now transition height to auto?
Because interpolate-size: allow-keywords opts an element and its descendants into interpolating intrinsic size keywords. Internally the browser resolves auto into a calc-size() value it knows how to interpolate, so a transition between 0 and auto produces real intermediate heights instead of snapping.
What does the name attribute on details do?
Details elements sharing the same name value form an exclusive group, so opening one automatically closes the others. It replaces the JavaScript that accordion widgets traditionally used to enforce one-open-at-a-time behaviour, and the grouped elements do not need to be DOM siblings.
Is the native details element more accessible than a checkbox hack?
Yes. <details> and <summary> expose a real disclosure with a managed expanded state, keyboard operation, and find-in-page expansion for free. A hidden checkbox exposes a checkbox role instead and needs hand-written ARIA and scripting to describe the same interaction.
What happens in a browser without ::details-content support? The rule is discarded as an unknown selector and the accordion still opens and closes instantly, because that behaviour comes from the element rather than from CSS. Only the height animation is lost.
Related
- Keyframe Animation Patterns — the parent guide for script-free interactive components.
- @starting-style entry animations — animate panels that are inserted rather than toggled.
- CSS-only toggle switches and checkboxes — the sibling pattern for controls that really are checkboxes.
- Creating accessible focus indicators — style the focus ring the summary element needs.
- Responsive forms with container queries — the layout side of collapsible filter panels.
Related articles
More pages in the same section.