Skip to content

Instantly share code, notes, and snippets.

@gursuj
Created May 27, 2026 05:46
Show Gist options
  • Select an option

  • Save gursuj/15aa5ddbf2e3e61e9ac64dcfc65524e9 to your computer and use it in GitHub Desktop.

Select an option

Save gursuj/15aa5ddbf2e3e61e9ac64dcfc65524e9 to your computer and use it in GitHub Desktop.
Animating `<details>` Accordions

Animating <details> Accordions

Why CSS-only fails for close

The browser removes the open attribute synchronously when the user clicks a summary. Any CSS rule tied to details[open] stops matching instantly — before a transition can play. So close never animates with CSS alone.

CSS grid-rows (grid-template-rows: 0fr / 1fr) works for opening but transitionend on fr units is also unreliable across browsers, so it's not a safe base for JS-driven animation either.

Use explicit pixel heights animated via JS.

CSS setup

Hide content by default with overflow: hidden; height: 0. No transition defined here — that's set inline by JS per-animation.

.faq-body {
    overflow: hidden;
    height: 0;
}

Wrap the answer content in .faq-body > inner div. The inner div gives a reliable offsetHeight measurement even when the parent is collapsed.

<details class="group">
    <summary>Question text</summary>
    <div class="faq-body">
        <div class="pb-6">Answer content</div>
    </div>
</details>

JS pattern

Key points:

  • querySelectorAll is evaluated once (static NodeList) — safe to mutate attributes inside forEach
  • Guard if (!body) return — other elements on the page (e.g. nav dropdowns) may also use details.group and will crash the loop if they lack .faq-body
  • details.setAttribute('open', '') before measuring — content must be in the DOM for offsetHeight to return the real height
  • Double requestAnimationFrame ensures the browser registers the start height before transitioning
  • After open: set height: auto (not clear cssText) — clearing would restore height: 0 from the CSS rule
document.querySelectorAll('details.group:not([data-faq-init])').forEach(function(details) {
    details.dataset.faqInit = '1';
    var body = details.querySelector('.faq-body');
    if (!body) { return; }               // skip non-FAQ details elements
    var inner = body.querySelector('div');

    details.querySelector('summary').addEventListener('click', function(e) {
        e.preventDefault();

        if (details.hasAttribute('open')) {
            // --- CLOSE ---
            var h = inner.offsetHeight;
            body.style.height = h + 'px';
            requestAnimationFrame(function() {
                requestAnimationFrame(function() {
                    body.style.transition = 'height 0.28s ease';
                    body.style.height = '0';
                });
            });
            body.addEventListener('transitionend', function done() {
                body.removeEventListener('transitionend', done);
                details.removeAttribute('open');
                body.style.cssText = '';         // safe to clear on close
            });
        } else {
            // --- OPEN ---
            details.setAttribute('open', '');    // render content first
            var target = inner.offsetHeight;     // measure after render
            body.style.height = '0';
            requestAnimationFrame(function() {
                requestAnimationFrame(function() {
                    body.style.transition = 'height 0.28s ease';
                    body.style.height = target + 'px';
                });
            });
            body.addEventListener('transitionend', function done() {
                body.removeEventListener('transitionend', done);
                body.style.height = 'auto';      // allow reflow at any width
                body.style.transition = '';
            });
        }
    });
});

Common mistakes

Mistake Result
transitionend on grid-template-rows fr values Unreliable — often doesn't fire
body.style.cssText = '' after open Restores CSS height: 0 — content collapses
No if (!body) return guard Script crashes on first non-FAQ details.group, all subsequent items uninitialised
Single RAF instead of double Start height not registered — no transition plays
Measuring offsetHeight before setAttribute('open') Measures 0 — content not in DOM yet
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment