Animating details and summary with ::details-content

Part of Discrete & Intrinsic-Size Animation in Modern View Transitions & Scroll APIs.

The problem

The native <details> element is the best-behaved disclosure widget on the web: it toggles with Enter and Space, exposes its expanded state to assistive technology, participates in find-in-page and needs no script. Its one weakness has always been motion. Clicking the summary makes the content appear or disappear on a single frame, and until recently there was no box you could target to animate, because the content was not wrapped in anything styleable.

Teams that wanted a smooth accordion therefore rebuilt it from <div> elements and ARIA attributes, reintroducing every keyboard and state bug the native element had already solved — or they attached a script that intercepted the toggle, measured the content and animated a wrapper.

Root cause: a hidden slot with a discrete visibility switch

Inside the browser, <details> has a shadow tree with two slots: one for the <summary>, and one for everything else. When the element is closed, the second slot is hidden with content-visibility: hidden, which skips its rendering entirely. When open is added, that value flips to visible.

Two things stood in the way of animating that. The slot was not exposed to author styles, so there was nothing to put a transition on. And content-visibility is a discrete property, so even with a selector it would switch instantly.

The ::details-content pseudo-element exposes the slot. transition-behavior: allow-discrete lets content-visibility wait: when closing, it stays visible until the transition ends. Combined with keyword size interpolation, the slot can shrink from its natural height to zero and only then stop rendering.

What the content slot does across a toggleallow-discrete holds content-visibility at visible for the length of the closing transition.What the content slot does across a toggleClosed, skippedOpeningOpen, visibleClosingopen addedsize reaches autoopen removedsize reaches 0
allow-discrete holds content-visibility at visible for the length of the closing transition.

Step-by-step resolution

Animating a native disclosureNo script, no ARIA, no wrapper elements.Animating a native disclosure1Start from plain details and summary markup; do not replace it.State and keyboard handling stay native2Enable interpolate-size: allow-keywords on :root.block-size can reach auto3On details::details-content set block-size: 0, overflow: clip and the transition list.The slot is the animating box4Include content-visibility in the transition with allow-discrete.Content keeps rendering while it closes5On details[open]::details-content set block-size: auto.Opening target is the natural height6Group exclusive panels with a shared name attribute and test the swap.
No script, no ARIA, no wrapper elements.

Exclusive accordions — where opening one panel closes its siblings — are native too. Give every <details> in the group the same name attribute. The browser closes the previously open panel when another opens, and because each panel runs its own transition, the closing and opening animations play at the same time without any coordination code.

Production code pattern

<div class="faq">
  <details name="faq">
    <summary>Does this need JavaScript?</summary>
    <p>No. The open state, keyboard toggle and exclusivity are all native.</p>
  </details>
  <details name="faq">
    <summary>Is it announced correctly?</summary>
    <p>Yes. The summary exposes an expanded or collapsed state automatically.</p>
  </details>
</div>
:root {
  interpolate-size: allow-keywords;     /* lets block-size interpolate to auto */
}

.faq details::details-content {
  block-size: 0;
  overflow: clip;                       /* hide content while the slot is shorter */
  transition:
    block-size 240ms cubic-bezier(0.2, 0.8, 0.2, 1),
    content-visibility 240ms allow-discrete;  /* stay rendered until the close ends */
}

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

/* Chevron rotation: composite-only feedback that the state changed. */
.faq summary::after {
  content: "";
  transition: rotate 240ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.faq details[open] summary::after {
  rotate: 90deg;
}

@media (prefers-reduced-motion: reduce) {
  .faq details::details-content {
    transition: content-visibility 0s allow-discrete;  /* instant open and close */
  }
  .faq summary::after { transition: none; }
}

Rendering Impact: layout for the slot, composite for the chevron. The slot’s block size changes every frame, so the panel and everything below it are laid out; the rotate on the marker runs on the compositor.

No @starting-style is needed here, unlike a panel hidden with display: none. The slot is never removed from the box tree — content-visibility: hidden skips rendering its contents but keeps the slot itself, so it always has a computed block-size of 0 to start from.

Scripted accordion against the native elementThe animation looks the same; the maintenance surface does not.Scripted accordion against the native elementdiv + ARIA + scriptaria-expanded kept in sync by handKeyboard handling reimplementedHidden text missing from find-in-pageMore code, more bugsdetails + ::details-contentState exposed by the browserEnter and Space work nativelyFind-in-page opens the matching panelMotion only in CSS
The animation looks the same; the maintenance surface does not.

Closed <details> content is searchable. When the browser’s find-in-page lands on text inside a closed panel, it adds open automatically — and because that is an ordinary attribute change, the opening transition plays. The same happens when a URL fragment targets an element inside a closed panel. You do not need to handle either case, but you should test them: a very long opening transition delays the scroll to the match, which makes find-in-page feel broken. Keep the duration under about 300ms.

If you listen for the toggle event to lazy-load panel content, note that it fires after the state changes, not after the transition ends. Content inserted at that point enlarges the resolved auto target mid-flight, and the panel retargets. Load the content inside the panel ahead of time, or reserve its space with min-block-size.

Verification checklist

Constraints and trade-offs

  • Browsers without ::details-content ignore the rules and fall back to the instant native toggle, which remains fully functional.
  • The slot’s block-size animation is a layout animation; a long FAQ page with a tall panel near the top reflows the whole list on every frame.
  • overflow: clip on the slot clips focus rings drawn outside descendants; use an inset outline on focusable children.
  • Styling summary with display: flex removes its default disclosure triangle in some browsers; provide your own marker.
  • Animating the details element itself instead of the slot moves the summary too, which shifts the click target during the animation.

Frequently asked questions

Why animate ::details-content instead of the details element?

The summary lives inside the details element. Animating the whole element changes the summary’s box as well, while the pseudo-element contains only the collapsible part, so the heading stays still and only the content grows.

Does the name attribute work with the animation?

Yes. Named details elements form an exclusive group handled by the browser. When one opens, the other loses its open attribute, and each runs its own transition at the same time.

Is content inside a closed details element still indexed and searchable?

It is present in the document, so search engines see it, and browser find-in-page can match it and open the panel. content-visibility: hidden skips rendering, not existence.

What happens in a browser that does not support ::details-content?

The selector does not match, so none of the styles apply and the details element toggles instantly as it always has. Nothing breaks.