Skip to content
All notes

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.

accordion.tsx

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 <button
26 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 <div
40 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

example.tsx
1import { Accordion } from "./accordion";
2
3export function Faq() {
4 return (
5 <Accordion
6 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.