Disclosure
Height is the whole problem: height:auto is not animatable, so implementations either hardcode a max-height — which clips long content and eases at the wrong speed for short content, because the transition runs over a height the panel never reaches — or measure in JavaScript and write inline styles they then have to clean up. This animates grid-template-rows from 0fr to 1fr, so the row sizes itself to the content: exact height, no measurement, no magic number, and still correct if the content reflows mid-animation.
Usage
/* Deepak Kumar E — https://craft.iam-deepak.space */ import { Disclosure } from "@/components/disclosure"; <Disclosure />Customise
The component
components/craft/interface/Disclosure.tsx
/* Deepak Kumar E — https://craft.iam-deepak.space */ import { useId, useRef, useState } from "react"; /** * An accordion that animates to its content's real height. * * Height is the whole problem. `height: auto` is not animatable, so the usual * fixes are a hardcoded max-height — which either clips long content or makes * short content ease at the wrong speed, because the transition runs over a * height the panel never reaches — or a JavaScript measurement written to an * inline style, which then has to be cleaned up so the panel can reflow when * the viewport changes. * * This uses `grid-template-rows: 0fr → 1fr`. The row sizes to its content * automatically, so it animates to the exact height with no measurement, no * magic number, and nothing to clean up — and it stays correct if the content * reflows mid-animation. * * The semantics are a plain button controlling a region: aria-expanded on the * trigger, aria-controls pointing at the panel, and the panel labelled by the * trigger. Nothing here is a custom widget, so the browser's own behaviour is * left alone. */ export interface DisclosureItem { id: string; question: string; answer: string;} export interface DisclosureProps { items: DisclosureItem[]; /** Allow several panels open at once. */ multiple?: boolean; /** Index open on first render, or null for all closed. */ defaultOpen?: number | null; accent?: string; /** Seconds. */ duration?: number; still?: boolean; className?: string;} export function Disclosure({ items, multiple = false, defaultOpen = null, accent = "#c3f53c", duration = 0.4, still = false, className = "",}: DisclosureProps) { const groupId = useId(); const [open, setOpen] = useState<Set<number>>( () => new Set(defaultOpen === null ? [] : [defaultOpen]), ); const listRef = useRef<HTMLDivElement>(null); const toggle = (index: number) => { setOpen((current) => { const next = new Set(multiple ? current : []); if (current.has(index)) next.delete(index); else next.add(index); return next; }); }; /** * Up/Down move between triggers, Home/End jump to the ends. Not required by * the pattern — each trigger is a button and Tab already reaches it — but it * is what people expect from a stack of related controls, and it costs * nothing to honour. */ const onKeyDown = (event: React.KeyboardEvent, index: number) => { const keys = ["ArrowDown", "ArrowUp", "Home", "End"]; if (!keys.includes(event.key)) return; event.preventDefault(); const next = event.key === "ArrowDown" ? (index + 1) % items.length : event.key === "ArrowUp" ? (index - 1 + items.length) % items.length : event.key === "Home" ? 0 : items.length - 1; listRef.current ?.querySelector<HTMLButtonElement>(`[data-trigger="${next}"]`) ?.focus(); }; return ( <div ref={listRef} className={`divide-y divide-ink-600 border-y border-ink-600 ${className}`}> {items.map((item, index) => { const isOpen = open.has(index); const panelId = `${groupId}-panel-${index}`; const triggerId = `${groupId}-trigger-${index}`; return ( <div key={item.id}> <h3> <button type="button" id={triggerId} data-trigger={index} aria-expanded={isOpen} aria-controls={panelId} onClick={() => toggle(index)} onKeyDown={(e) => onKeyDown(e, index)} className="flex w-full items-center justify-between gap-4 py-4 text-left" > <span className="text-[0.95rem] transition-colors" style={{ color: isOpen ? accent : undefined }} > {item.question} </span> {/* A plus rotating into a minus: one element, no icon swap, and it reads as a state change rather than a replacement. */} <span aria-hidden className="relative h-3 w-3 shrink-0" style={{ transform: isOpen ? "rotate(180deg)" : "rotate(0deg)", transition: still ? "none" : `transform ${duration}s cubic-bezier(0.16,1,0.3,1)`, }} > <span className="absolute top-1/2 left-0 h-px w-full -translate-y-1/2" style={{ background: isOpen ? accent : "currentColor" }} /> <span className="absolute top-0 left-1/2 h-full w-px -translate-x-1/2" style={{ background: isOpen ? accent : "currentColor", transform: isOpen ? "scaleY(0)" : "scaleY(1)", transition: still ? "none" : `transform ${duration}s cubic-bezier(0.16,1,0.3,1)`, }} /> </span> </button> </h3> {/* 0fr → 1fr. The row sizes itself to the content, so this animates to the exact height with no measurement. */} <div id={panelId} role="region" aria-labelledby={triggerId} className="grid" style={{ gridTemplateRows: isOpen ? "1fr" : "0fr", transition: still ? "none" : `grid-template-rows ${duration}s cubic-bezier(0.16,1,0.3,1)`, }} > {/* The overflow-hidden child is required: without it the content spills out of a 0fr row instead of being clipped by it. */} <div className="overflow-hidden"> <p className="pb-5 text-[0.9rem] leading-relaxed text-bone-400" // Hidden from assistive tech when collapsed, or a screen // reader reads answers the sighted user cannot see. {...(isOpen ? {} : { "aria-hidden": true })} > {item.answer} </p> </div> </div> </div> ); })} </div> );} One file. Paste it in, delete what you do not need, change what you do. The "use client" directive is stripped above — add it back if you are on the Next.js App Router.
What it costs
- Weight
+2KB gzipped · no new dependencies
- Runs on
Main thread
Runs JavaScript every frame. Heavy work here competes with your own code and with scrolling — watch the per-frame budget below.
- Reduced motion
Panels open and close instantly. The disclosure state is carried by aria-expanded, so nothing is lost with the animation removed.
- Accessibility
- A plain button with aria-expanded and aria-controls — not a custom widget, so the browser's own behaviour is left intact.
- The panel is a region labelled by its trigger.
- Collapsed answers are aria-hidden, so a screen reader does not read content the sighted user cannot see.
- Up/Down move between triggers, Home and End jump to the ends.
- Watch out
- grid-template-rows animation needs the overflow:hidden child. Remove it and the content spills out of the 0fr row instead of being clipped.
- Animating grid-template-rows triggers layout each frame. Smooth for a normal FAQ; not something to run on fifty simultaneously.