Accordion
Sections folded behind their headers, with hairlines between them rather than a box round each.
The idea
The fold is Disclosure's: height animates while the content stays mounted, so every answer is in the served HTML, and a closed one is inert so the keyboard meets headers, not hidden prose. Opening eases out with the body rising into place; closing is quicker and flat. The rows are one list, ruled with hairlines — a card per row would turn a column of questions into a pile of tiles. Up and Down move between headers.
Composes Disclosure.
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 { Accordion } from "mihirmodi-ui";
// Closed answers stay in the DOM — crawlable — and inert.
<Accordion
type="single"
defaultValue="seats"
items={[
{ value: "seats", title: "What counts as a seat?", content: "Anyone who can edit." },
{ value: "pause", title: "Can I pause instead?", content: "Yes, for up to three months." },
]}
/>
// Several open at once, controlled.
<Accordion type="multiple" value={open} onChange={setOpen} items={sections} />Props
| Prop | Type | Notes |
|---|---|---|
| items{ value, title, content, disabled? }[] | { value, title, content, disabled? }[] | The sections, in order. |
| type"single" | "multiple" | "single" | "multiple"= "single" | One open at a time, or several. |
| value / defaultValue / onChangestring | null, or string[] | string | null, or string[] | Controlled or not; the shape follows `type`. |
| collapsibleboolean | boolean= true | Single only: whether the open section can close to none. |
| headingLevel2–6 | 2–6= 3 | The heading each header button sits in. |
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 { useId, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import { LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
import { ChevronDownIcon } from "../../lib/glyph.js";
import { EASE_OUT_EXPO } from "../../lib/motion.js";
export type AccordionItem = {
value: string;
/** The header's label. */
title: ReactNode;
/** The section's body. It stays in the DOM while closed. */
content: ReactNode;
disabled?: boolean;
};
type Single = {
type?: "single";
/** Controlled: the open section, or `null` for none. */
value?: string | null;
defaultValue?: string | null;
onChange?: (value: string | null) => void;
/** Let the open section close again, leaving none open. Default true. */
collapsible?: boolean;
};
type Multiple = {
type: "multiple";
value?: string[];
defaultValue?: string[];
onChange?: (value: string[]) => void;
};
export type AccordionProps = (Single | Multiple) & {
items: AccordionItem[];
/** The level of each header's heading — match the outline of the page it sits in. */
headingLevel?: 2 | 3 | 4 | 5 | 6;
className?: string;
};
/**
* A stack of sections, each folded behind its header.
*
* Hairline rules between the rows and nothing else — not a box per row. The
* sections are one list on the page, and a card around each would turn a
* column of questions into a pile of tiles. A header is a 14px row at 500
* with a chevron that turns over as its section opens; the body is the muted
* tier under it, so header and answer separate on weight and colour.
*
* The fold is Disclosure's: height animates while the content stays mounted,
* so every answer is in the served HTML for a crawler, and a closed one is
* `inert` so a tab press or a screen reader meets the headers, not hidden
* prose. Opening eases out over 280ms with the body rising 4px into place;
* closing is quicker and flat, because a section going away needs nothing
* from the reader. Under reduced motion the fold is instant.
*
* `single` keeps one section open at a time, which suits a FAQ where answers
* are read one by one; `multiple` lets several stay open for comparison. The
* headers are buttons inside headings, and Up/Down, Home and End move between
* them, so a long list is walkable without tabbing through every answer.
*/
export function Accordion(props: AccordionProps) {
const { items, headingLevel = 3, className = "" } = props;
const reduce = useReducedMotion();
const base = useId();
const multiple = props.type === "multiple";
const headers = useRef<(HTMLButtonElement | null)[]>([]);
const [inner, setInner] = useState<string[]>(() => {
if (props.type === "multiple") return props.defaultValue ?? [];
return props.defaultValue != null ? [props.defaultValue] : [];
});
const controlled = props.value !== undefined;
const open: string[] = controlled
? props.type === "multiple"
? (props.value ?? [])
: props.value != null
? [props.value]
: []
: inner;
const toggle = (v: string) => {
let next: string[];
if (props.type === "multiple") {
next = open.includes(v) ? open.filter((x) => x !== v) : [...open, v];
props.onChange?.(next);
} else {
const collapsible = props.collapsible ?? true;
const was = open.includes(v);
if (was && !collapsible) return;
next = was ? [] : [v];
props.onChange?.(next[0] ?? null);
}
if (!controlled) setInner(next);
};
const onKey = (e: KeyboardEvent<HTMLButtonElement>, i: number) => {
const live = items.map((it, n) => (it.disabled ? -1 : n)).filter((n) => n >= 0);
const at = live.indexOf(i);
let to: number | undefined;
if (e.key === "ArrowDown") to = live[(at + 1) % live.length];
else if (e.key === "ArrowUp") to = live[(at - 1 + live.length) % live.length];
else if (e.key === "Home") to = live[0];
else if (e.key === "End") to = live[live.length - 1];
if (to == null) return;
e.preventDefault();
headers.current[to]?.focus();
};
const H = `h${headingLevel}` as "h3";
return (
<LazyMotion features={domAnimation} strict>
<div data-type={multiple ? "multiple" : "single"} className={`flex flex-col divide-y-[0.5px] divide-gray-alpha-400 ${className}`}>
{items.map((it, i) => {
const isOpen = open.includes(it.value);
const head = `${base}-h-${i}`;
const body = `${base}-b-${i}`;
return (
<div key={it.value} data-open={isOpen || undefined}>
<H className="m-0">
<button
ref={(el) => {
headers.current[i] = el;
}}
id={head}
type="button"
aria-expanded={isOpen}
aria-controls={body}
disabled={it.disabled}
onClick={() => toggle(it.value)}
onKeyDown={(e) => onKey(e, i)}
className="group flex w-full items-center gap-3 py-3.5 text-left text-[14px] font-medium leading-[1.4] text-gray-1000 transition-opacity duration-150 ease-out hover:opacity-70 disabled:opacity-40"
>
<span className="min-w-0 flex-1">{it.title}</span>
<ChevronDownIcon
className={`h-3.5 w-3.5 shrink-0 text-faint transition-transform duration-200 ease-geist motion-reduce:transition-none ${
isOpen ? "rotate-180" : ""
}`}
/>
</button>
</H>
<m.div
id={body}
role="region"
aria-labelledby={head}
// As in Disclosure: no animation on mount, so a section that
// starts closed does not visibly fold shut on load.
initial={false}
animate={{ height: isOpen ? "auto" : 0 }}
transition={
reduce ? { duration: 0 } : isOpen ? { duration: 0.28, ease: EASE_OUT_EXPO } : { duration: 0.18, ease: "easeIn" }
}
inert={!isOpen}
aria-hidden={!isOpen}
className="overflow-hidden"
>
<m.div
initial={false}
animate={isOpen ? { opacity: 1, y: 0 } : { opacity: 0, y: reduce ? 0 : 4 }}
transition={reduce ? { duration: 0 } : isOpen ? { duration: 0.24, delay: 0.04, ease: EASE_OUT_EXPO } : { duration: 0.12 }}
className="pb-4 pr-7 text-[14px] leading-[1.65] text-muted"
>
{it.content}
</m.div>
</m.div>
</div>
);
})}
</div>
</LazyMotion>
);
}
"use client";
import { useCallback, useEffect, useId, useState, type ReactNode } from "react";
import { LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
import { ChevronDownIcon } from "../lib/glyph.js";
/**
* 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>
);
}