July 22, 2026 · 6 min
An accordion with a real height animation
No measuring `scrollHeight`. CSS grid `0fr` / `1fr` plus `aria-expanded` gives you a disclosure widget that actually animates.
- React
- TypeScript
- CSS
Try it outClick around. This is the real component.
Preview
The row size is the content. The animation duration matches what is actually opening.
The trigger is a button with aria-expanded. The panel is a region labelled by that button.
Swap the single openId for a Set. The animation does not change.
1"use client";2 3import { useId, useState } from "react";4 5type Item = {6 id: string;7 title: string;8 content: React.ReactNode;9};10 11export function Accordion({ items }: { items: Item[] }) {12 const baseId = useId();13 const [openId, setOpenId] = useState<string | null>(items[0]?.id ?? null);14 15 return (16 <div className="divide-y divide-white/10 rounded-2xl border border-white/10">17 {items.map((item) => {18 const open = item.id === openId;19 const buttonId = `${baseId}-${item.id}-button`;20 const panelId = `${baseId}-${item.id}-panel`;21 22 return (23 <div key={item.id}>24 <h3>25 <button26 type="button"27 id={buttonId}28 aria-expanded={open}29 aria-controls={panelId}30 onClick={() => setOpenId(open ? null : item.id)}31 className="flex w-full items-center justify-between gap-4 px-4 py-3 text-left text-sm font-medium"32 >33 {item.title}34 <span aria-hidden className="text-zinc-500">35 {open ? "×" : "+"}36 </span>37 </button>38 </h3>39 <div40 id={panelId}41 role="region"42 aria-labelledby={buttonId}43 className="grid transition-[grid-template-rows] duration-300 ease-out"44 style={{ gridTemplateRows: open ? "1fr" : "0fr" }}45 >46 <div className="overflow-hidden">47 <div className="px-4 pb-4 text-sm leading-relaxed text-zinc-400">48 {item.content}49 </div>50 </div>51 </div>52 </div>53 );54 })}55 </div>56 );57}The classic accordion trick is max-height: 0 to max-height: 999px. It feels mushy because the animation duration is tied to a fake ceiling, not the content. The modern version is a grid with one row that goes from 0fr to 1fr.
That, plus a button with aria-expanded and a region with aria-labelledby, is a complete disclosure component.
Usage
1import { Accordion } from "./accordion";2 3export function Faq() {4 return (5 <Accordion6 items={[7 {8 id: "why",9 title: "Why not max-height?",10 content: "The duration stops matching the content the moment copy changes.",11 },12 {13 id: "multi",14 title: "Can several panels be open?",15 content: "Store a Set of ids instead of a single openId.",16 },17 ]}18 />19 );20}Exclusive vs multiple
This version is exclusive: opening one panel closes the others, which is what you want for FAQs. For a settings page, switch openId to Set<string> and toggle membership on click. Same animation, same ARIA.