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.
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>Key points:
querySelectorAllis 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 usedetails.groupand will crash the loop if they lack.faq-body details.setAttribute('open', '')before measuring — content must be in the DOM foroffsetHeightto return the real height- Double
requestAnimationFrameensures the browser registers the start height before transitioning - After open: set
height: auto(not clear cssText) — clearing would restoreheight: 0from 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 = '';
});
}
});
});| 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 |