Disclosure
A panel that folds open by height, with rows that arrive one at a time — once.
The idea
Collapsing by animating height rather than unmounting keeps the rows in the served HTML, which is what makes them worth anything to a crawler, and `inert` keeps them out of the tab order while closed. The staggered blur-in is a welcome: with a key it plays on the first open of a session and then gets out of the way, because the second time it would only be a delay.
Install
npm i @mihirmodi/ui motionThe tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.
Usage
import { Disclosure } from "@mihirmodi/ui";
// The rows stay in the DOM when closed — they fold by height, not by
// unmounting — so they are in the served HTML. With `once` the staggered
// reveal plays on the first open of a session and never again; leave it
// out and it plays every time.
<Disclosure
summary="The short version"
icon={<SparkIcon />}
once={`study:${slug}`}
items={study.summary.map((line) => (
<span key={line} className="flex gap-2.5">
<span aria-hidden className="mt-[0.68em] h-px w-2.5 shrink-0 bg-gray-500" />
{line}
</span>
))}
footer="Written by me, not a model."
/>Props
| Prop | Type | Notes |
|---|---|---|
| summaryReactNode | ReactNode | The label on the row you press. |
| iconReactNode | ReactNode | A mark before the label. Small — it shares the row. |
| itemsReactNode[] | ReactNode[] | The rows inside. Each arrives on its own beat the first time the panel opens. |
| footerReactNode | ReactNode | Under the rows, revealed with the panel but not staggered. A provenance line, a link. |
| oncestring | string | Play the stagger once per session, remembered under this key. Omit it and every open gets the full performance. |
| defaultOpenboolean | boolean= false | Start open. It still folds by height, so nothing animates shut on mount. |
| classNamestring | string | On the outer panel. The border, radius and background live there, so this is where to restyle them. |
Source
Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.
"use client";
import { useCallback, useEffect, useId, useState, type ReactNode } from "react";
import { LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
import { ChevronDownIcon } from "../lib/glyph";
/**
* Expo-out. Deliberately not `--ease-geist`, whose curve overshoots past 1 —
* fine on a transform, wrong here, where the same track drives a blur that
* would have to pass through a negative radius to get back.
*/
const EASE = [0.16, 1, 0.3, 1] as const;
/** Per row, and short enough that three of them read as one movement. */
const STAGGER = 0.09;
export type DisclosureProps = {
/** The label on the row you press. */
summary: ReactNode;
/** A mark before the label. Optional, and small — it shares the row. */
icon?: ReactNode;
/** The rows inside. Each arrives on its own beat the first time the panel opens. */
items: ReactNode[];
/** Anything under the rows: a provenance line, a link. Revealed with the panel, not staggered. */
footer?: ReactNode;
/**
* Play the staggered reveal once per session, remembered under this key in
* `sessionStorage`. Leave it out and every open gets the full performance —
* right for a demo, wrong for a panel a reader will open on ten pages.
*/
once?: string;
defaultOpen?: boolean;
/** On the outer panel — the border, the radius and the background live here. */
className?: string;
};
/**
* A collapsible panel whose contents arrive one row at a time.
*
* ── Why the rows stay in the DOM ──────────────────────────────────────────────
*
* The panel collapses by animating its height, not by unmounting — so what is
* inside is in the served HTML, which is what makes it worth anything to a
* crawler or to `llms.txt`. It is `inert` while closed, so a screen reader or a
* tab press meets the button rather than three rows of hidden prose.
*
* ── Why the stagger plays once ────────────────────────────────────────────────
*
* Rows blurring in one after another is a welcome. It says "here is the short
* one" the first time, and it would say "wait" every time after that, so with
* `once` set it plays on the first open of a session and then gets out of the
* way: later opens are a plain fold. The label takes one pass of light as it
* opens for the same reason and with the same limit — a looping shimmer would
* claim the thing is still thinking, which it is not and never was.
*
* Under reduced motion the fold is instant and the rows simply appear.
*
* <Disclosure summary="The short version" icon={<Spark />} once="study:atlas"
* items={["Three claims.", "Side by side.", "Not a list of features."]}
* footer="Written by me, not a model." />
*/
export function Disclosure({
summary,
icon,
items,
footer,
once,
defaultOpen = false,
className = "",
}: DisclosureProps) {
const reduce = useReducedMotion();
const [open, setOpen] = useState(defaultOpen);
const [seen, setSeen] = useState(false);
const [sheen, setSheen] = useState(false);
const panelId = useId();
// Read in an effect rather than during render: `sessionStorage` does not
// exist on the server, and seeding state from it would hand React two
// different first paints to reconcile.
useEffect(() => {
if (!once) return;
try {
if (sessionStorage.getItem(`disclosure:${once}`)) setSeen(true);
} catch {
// Private mode or blocked storage — the panel just plays every time.
}
}, [once]);
// The full performance is for the first open only, and never against a
// reduced-motion preference.
const theatre = !reduce && !seen;
const toggle = useCallback(() => {
setOpen((was) => {
const next = !was;
if (next && theatre) setSheen(true);
if (next && once) {
try {
sessionStorage.setItem(`disclosure:${once}`, "1");
} catch {
// ignore — it will simply play again next time
}
}
return next;
});
}, [once, theatre]);
return (
<div
data-open={open}
className={`overflow-hidden rounded-[14px] border-[0.5px] border-gray-alpha-400 bg-background-100 ${className}`}
>
<button
type="button"
onClick={toggle}
aria-expanded={open}
aria-controls={panelId}
className="flex w-full items-center gap-2.5 px-4 py-3 text-left text-[13px] font-medium text-gray-1000 transition-opacity duration-150 ease-geist hover:opacity-70"
>
{icon && <span className="flex shrink-0 items-center">{icon}</span>}
<span
// Remounted on each open so the sheen keyframe runs from the top.
key={String(open)}
className={sheen && open ? "disclosure-sheen" : ""}
onAnimationEnd={() => setSheen(false)}
>
{summary}
</span>
<ChevronDownIcon
className={`ml-auto h-3.5 w-3.5 shrink-0 text-faint transition-transform duration-200 ease-geist ${
open ? "rotate-180" : ""
}`}
/>
</button>
<LazyMotion features={domAnimation} strict>
<m.div
id={panelId}
// `initial={false}` so a panel that starts closed does not animate shut
// on mount — the reader would see a flicker of rows collapsing.
initial={false}
animate={{ height: open ? "auto" : 0 }}
transition={{ duration: reduce ? 0 : 0.28, ease: EASE }}
inert={!open}
aria-hidden={!open}
className="overflow-hidden"
>
<m.ul
initial={false}
animate={open ? "open" : "shut"}
variants={{
open: {
transition: {
staggerChildren: theatre ? STAGGER : 0,
delayChildren: theatre ? 0.06 : 0,
},
},
}}
// Marked seen once the first open has finished rather than when it
// starts — setting it any earlier lands in the same batch as `open`
// and cancels the very animation it is recording. Only with `once`:
// a panel without a key plays every time by design.
onAnimationComplete={() => {
if (open && once) setSeen(true);
}}
className="flex flex-col gap-3 px-4 pb-1"
>
{items.map((item, i) => (
<m.li
key={i}
// The shut state is a constant, and it has to be. `theatre` is
// false on the server and true on the client for most, so
// branching the rendered styles on it would hand React two
// different first paints. Only the transitions read it.
variants={{
shut: { opacity: 0, y: 6, filter: "blur(4px)" },
open: {
opacity: 1,
y: 0,
filter: "blur(0px)",
transition: { duration: reduce ? 0 : theatre ? 0.34 : 0.16, ease: EASE },
},
}}
className="text-[13.5px] leading-[1.6] text-gray-1000"
>
{item}
</m.li>
))}
</m.ul>
{footer && <div className="px-4 pb-3.5 pt-4 text-[12px] leading-[1.5] text-faint">{footer}</div>}
</m.div>
</LazyMotion>
</div>
);
}