Collapsible HTML: Why This Codebase Skips the Details Element
Zero details elements across 91 components, and the CSS grid-template-rows trick FaqItem uses instead — plus when the native element is the right call.
Collapsible HTML means one of two things: the native <details>/<summary> element, which needs no JavaScript, or a hand-built toggle — a button plus a <div> whose visibility is driven by state. This codebase has 91 components and exactly one collapsible pattern, and it's the second kind. Grep the whole src/ tree and there are zero <details> elements anywhere. The reason isn't unfamiliarity — it's that the one thing this codebase's collapsible needs, <details> doesn't give you for free.
What "collapsible" actually means here
FaqItem.tsx is the only collapsible section in this app, used eight times on /pricing. It's a button that toggles a panel:
// src/components/molecules/FaqItem.tsx
export default function FaqItem({ question, answer }: FaqEntry) {
const [open, setOpen] = useState(false);
const id = useId();
const buttonId = `${id}-button`;
const panelId = `${id}-panel`;
return (
<div className="rounded-3xl bg-gray-50">
<h3 className="m-0">
<button
type="button"
id={buttonId}
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
aria-controls={panelId}
className="flex w-full items-start justify-between gap-2 px-6 pt-6 pb-6 text-left text-lg font-medium text-title-color"
>
{question}
{/* chevron, rotated via a scale transform when open */}
</button>
</h3>
<div
id={panelId}
role={open ? "region" : undefined}
aria-labelledby={open ? buttonId : undefined}
className="grid transition-[grid-template-rows] duration-300"
style={{ gridTemplateRows: open ? "1fr" : "0fr" }}
>
<div className="overflow-hidden pl-6 transition-all duration-300">
<p className="pr-6 pb-6 text-base leading-7 text-text-color-secondary">
{answer}
</p>
</div>
</div>
</div>
);
}
Two things about this are easy to miss on a first read. First, each row is independent — there's no shared "only one open at a time" state, so opening a second question doesn't close the first. Second, the animation is the whole reason this isn't three lines shorter: grid-template-rows transitions from 0fr to 1fr on the wrapping <div>, with the actual content clipped by overflow-hidden on the child. That's a well-known trick for animating a height nobody knows in advance — height: auto has never been transitionable in CSS, and this sidesteps it without JavaScript measuring scrollHeight on every render. The accessibility wiring — the heading wrapper, aria-controls/aria-expanded, and a role="region" that only appears while the panel is open — is a separate fix, already covered in the React accordion post; this one is about the animation and the element choice, not the ARIA pattern.
Why not the native element
<details>/<summary> gives you a working, keyboard-accessible disclosure widget with zero JavaScript and zero ARIA to write by hand — the browser supplies the toggle semantics, the expanded state, and screen-reader support automatically. That's a real, substantial default, and it's the right call for plenty of collapsible content. It just doesn't cover what FaqItem needs:
| Requirement | Native <details> | This codebase's FaqItem |
|---|---|---|
| Zero-JS keyboard toggle | Yes, built in | Requires the onClick handler above |
| Accessible expanded state | Automatic (open attribute) | Manual aria-expanded/aria-controls wiring |
| Animated open/close | No transition by default — content appears and disappears in a single frame | grid-template-rows animates over 300ms |
| Icon that rotates with state | Not exposed as a style hook without extra CSS | Trivial — open is already a JS boolean |
| Styling the disclosure triangle | Requires ::marker/list-style: none overrides, browser-inconsistent | No default marker to fight |
| Works with zero JavaScript | Yes | No — this is a Client Component |
The animation row is the one that actually decided it. A <details> element's open attribute is a boolean, not a CSS custom property or a class — there's nothing to key a grid-template-rows transition off without JavaScript toggling a class or inline style in response to the toggle event, which erases most of the "zero JavaScript" benefit anyway. Once you're already listening for state changes to drive an animation, you're paying <details>'s cost (fighting the default marker, working around display: list-item quirks in older engines) without collecting its benefit.
When the native element is the right call
The trade isn't "always build it by hand" — it's specific to needing an animation. A collapsible section with no transition, where content can legitimately just appear — a "show raw error details" toggle in a developer tool, a changelog entry's older notes, an FAQ where a instant snap is acceptable — is exactly what <details> is for, and reaching for useState and a chevron SVG there is the wrong call in the other direction: more code, more client JavaScript, for a widget the browser already ships. This codebase's ChangelogList formats dates but doesn't collapse anything — every changelog entry renders in full — which is a different decision (show everything, since the list is short) rather than evidence either way on <details>.
What animating <details> anyway would actually cost
It's possible to keep the native element and still animate it — you just can't do it with a bare CSS transition. The toggle event fires when <details> opens or closes, which is enough to drive the same grid trick this codebase uses on FaqItem:
<details id="d">
<summary>Question</summary>
<div class="panel"><p>Answer text.</p></div>
</details>
<script>
const d = document.getElementById("d");
d.addEventListener("toggle", () => {
d.querySelector(".panel").style.gridTemplateRows = d.open ? "1fr" : "0fr";
});
</script>
That gets the animation back, but notice what it doesn't get back: <details> toggles its open attribute before the animation would run, so a closing transition has nowhere to animate from — the content is already gone from layout by the time toggle fires closed. Getting a closing animation working with <details> means intercepting the click on <summary>, calling preventDefault(), running your own animation, and only then setting open — at which point you've reimplemented the toggle behavior the element was supposed to hand you for free, and the "zero JavaScript" argument for using <details> in the first place no longer applies. That's the real reason this codebase didn't reach for it: not that <details> can't be animated at all, but that animating it well costs roughly the same code as FaqItem already has, minus the ability to control layout and styling as directly.
Common mistakes
| Mistake | Symptom | Fix |
|---|---|---|
Animating <details> with height: auto and a CSS transition | Nothing animates — auto was never a transitionable value | Use grid-template-rows: 0fr → 1fr (or JS-measured scrollHeight) on a wrapping element instead |
Styling <summary>'s default marker with display: none alone | The triangle disappears in most browsers but persists in older WebKit as a list-style artifact | Set list-style: none on the <summary> and hide the ::-webkit-details-marker pseudo-element |
A custom accordion built with only onClick, no aria-expanded | Keyboard and screen-reader users can't tell the panel is open | Wire aria-expanded/aria-controls as FaqItem does, or use <details> and get it for free |
Multiple independent <details> elements meant to act as an exclusive accordion | All of them can be open simultaneously | Native <details> has a name attribute (2023+ browsers) that groups them exclusively — or roll your own state if you need it |
Putting interactive content (a form, a nested button) inside <summary> | Focus and click handling get inconsistent across browsers | Keep <summary> to text/inline content; put interactive elements in the <details> body |
Assuming a collapsed <details> is invisible to search engines | Some crawlers don't index collapsed content the same as visible text | Don't hide content you want indexed behind a closed disclosure with no server-rendered fallback |
Frequently asked questions
Is <details> accessible by default?
Yes — the browser handles the expanded/collapsed state, exposes it to assistive tech, and makes <summary> keyboard-focusable and togglable with Enter or Space, with no ARIA attributes required. That built-in correctness is the strongest argument for reaching for it whenever the content doesn't need an animated transition.
Can you animate a <details> element at all?
Not with a plain CSS transition on height or grid-template-rows, because the element's internal layout isn't exposed as an animatable property by default. The two real options are JavaScript-driven (intercept the toggle event, then run the same kind of grid-rows trick this codebase uses) or accepting the instant snap and using <details> as-is.
Why does FaqItem use grid-template-rows instead of measuring scrollHeight in JavaScript?
scrollHeight requires a layout read on every open/close and a ResizeObserver if the content can change size later (a window resize wrapping the answer text differently, for instance). 0fr → 1fr is pure CSS once the class toggles — no measurement, no observer, and it correctly re-adapts if the panel's content height changes for any reason.
Does every collapsible section in this codebase animate?
No — FaqItem is the only one, and it animates because a snap felt wrong for content the visitor is actively reading through on /pricing. A collapsible with no animation requirement anywhere else in this app would be a legitimate candidate for <details> instead of a new Client Component.
Templates where this pattern already ships
ASoc Till is a point-of-sale landing page whose feature blocks and FAQ-style sections use the same disclosure pattern to keep a dense feature list scannable. ASoc Timbre pairs a multi-step how-it-works flow with collapsible pricing tiers, and ASoc Uptime uses the same shape for its services toolkit and launch-flow sections.
Browse the full sets: Next.js landing page templates and Tailwind landing page templates. For the ARIA-compliance half of this same component, read React accordion: the WAI-ARIA pattern, audited; for the matching question on this codebase's own navigation markup, HTML nav: one file, every breakpoint.
