Skip to content
Interface

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.

Loading preview…

Usage

/* Deepak Kumar E — https://craft.iam-deepak.space */ import { Disclosure } from "@/components/disclosure"; <Disclosure />

Customise

0.4s

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.