Skip to main content
ASoc
Tutorial

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.

The ASoc Team9 min read

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:

RequirementNative <details>This codebase's FaqItem
Zero-JS keyboard toggleYes, built inRequires the onClick handler above
Accessible expanded stateAutomatic (open attribute)Manual aria-expanded/aria-controls wiring
Animated open/closeNo transition by default — content appears and disappears in a single framegrid-template-rows animates over 300ms
Icon that rotates with stateNot exposed as a style hook without extra CSSTrivial — open is already a JS boolean
Styling the disclosure triangleRequires ::marker/list-style: none overrides, browser-inconsistentNo default marker to fight
Works with zero JavaScriptYesNo — 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

MistakeSymptomFix
Animating <details> with height: auto and a CSS transitionNothing animates — auto was never a transitionable valueUse grid-template-rows: 0fr → 1fr (or JS-measured scrollHeight) on a wrapping element instead
Styling <summary>'s default marker with display: none aloneThe triangle disappears in most browsers but persists in older WebKit as a list-style artifactSet list-style: none on the <summary> and hide the ::-webkit-details-marker pseudo-element
A custom accordion built with only onClick, no aria-expandedKeyboard and screen-reader users can't tell the panel is openWire 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 accordionAll of them can be open simultaneouslyNative <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 browsersKeep <summary> to text/inline content; put interactive elements in the <details> body
Assuming a collapsed <details> is invisible to search enginesSome crawlers don't index collapsed content the same as visible textDon'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.

Keep reading

Tutorial8 min read

What's Actually in a Data Dashboard? 59 Views, Counted

9 admin templates, 59 dashboard views, audited from the catalog — what a coded data dashboard actually contains versus a BI tool.

Read more
Tutorial12 min read

DB Schema: Two Meanings, Six Tables, and 271 Lines of Postgres

In Postgres “schema” means both the structure and a namespace. Here is a real commerce schema — 6 tables, 8 migrations, and the four times it had to change.

Read more