"use client";
import * as React from "react";
export interface FluidAccordionItem {
/** Stable, unique id. Used as the open value. */
id: string;
/** Header text. Rendered inside the trigger button. */
title: React.ReactNode;
/** Panel content, revealed when the item opens. */
content: React.ReactNode;
disabled?: boolean;
}
export interface FluidAccordionProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "defaultValue" | "onChange"> {
items: FluidAccordionItem[];
/** "single" keeps at most one item open; "multiple" lets any number stay open. */
type?: "single" | "multiple";
/** Open item ids (controlled). */
value?: string[];
/** Initially open item ids (uncontrolled). */
defaultValue?: string[];
/** Called with the next list of open ids. */
onValueChange?: (value: string[]) => void;
/** In single mode, allow closing the open item so none is open. */
collapsible?: boolean;
/** Indicator glyph: a rotating chevron or a plus that morphs into a minus. */
icon?: "chevron" | "plus";
/** "lined" separates items with hairlines; "cards" gives each item its own surface. */
variant?: "lined" | "cards";
/** Show a mono index (01, 02…) before each title. */
showIndex?: boolean;
/** Open / close duration in ms. */
duration?: number;
/** Heading level wrapping each trigger, to fit the page outline. */
headingLevel?: 2 | 3 | 4 | 5 | 6;
/** Accent for the open index, indicator and focus ring. */
accent?: string;
}
function Indicator({ kind, open }: { kind: "chevron" | "plus"; open: boolean }) {
if (kind === "chevron") {
return (
<svg
viewBox="0 0 16 16"
aria-hidden="true"
className={`h-4 w-4 shrink-0 transition-transform duration-[var(--fa-duration)] ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none ${
open ? "rotate-180 text-[var(--fa-accent)]" : "text-zinc-400 dark:text-zinc-500"
}`}
>
<path d="m4 6 4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
// Plus → minus: the vertical bar rotates 90° and lands on the horizontal one.
return (
<span
aria-hidden="true"
className={`relative flex h-4 w-4 shrink-0 items-center justify-center ${
open ? "text-[var(--fa-accent)]" : "text-zinc-400 dark:text-zinc-500"
}`}
>
<span className="absolute h-[1.5px] w-3 rounded-full bg-current" />
<span
className={`absolute h-3 w-[1.5px] rounded-full bg-current transition-transform duration-[var(--fa-duration)] ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none ${
open ? "rotate-90" : ""
}`}
/>
</span>
);
}
/**
* An accordion whose panels open with a fluid height animation (grid-template-rows 0fr → 1fr),
* so content of any height animates without measuring. Follows the WAI-ARIA accordion pattern.
*/
export function FluidAccordion({
items,
type = "single",
value,
defaultValue,
onValueChange,
collapsible = true,
icon = "chevron",
variant = "lined",
showIndex = true,
duration = 320,
headingLevel = 3,
accent = "#FF7448",
className = "",
style,
...props
}: FluidAccordionProps) {
const uid = React.useId();
const [internal, setInternal] = React.useState<string[]>(defaultValue ?? []);
const raw = value ?? internal;
const open = type === "single" ? raw.slice(0, 1) : raw;
const Heading = `h${headingLevel}` as "h2" | "h3" | "h4" | "h5" | "h6";
const toggle = (id: string) => {
const isOpen = open.includes(id);
let next: string[];
if (type === "single") {
if (isOpen && !collapsible) return;
next = isOpen ? [] : [id];
} else {
next = isOpen ? open.filter((v) => v !== id) : [...open, id];
}
if (value === undefined) setInternal(next);
onValueChange?.(next);
};
// Arrow keys, Home and End move focus between headers (WAI-ARIA accordion, optional keys).
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
const target = event.target as HTMLElement;
if (target.dataset.accordionTrigger !== uid) return;
const triggers = Array.from(
event.currentTarget.querySelectorAll<HTMLButtonElement>(`[data-accordion-trigger="${uid}"]:not(:disabled)`),
);
if (!triggers.length) return;
event.preventDefault();
const index = triggers.indexOf(target as HTMLButtonElement);
const last = triggers.length - 1;
const next =
event.key === "Home"
? 0
: event.key === "End"
? last
: event.key === "ArrowDown"
? (index + 1) % triggers.length
: (index - 1 + triggers.length) % triggers.length;
triggers[next].focus();
};
const isCards = variant === "cards";
return (
<div
className={`w-full ${isCards ? "flex flex-col gap-2" : "border-t border-zinc-900/10 dark:border-white/10"} ${className}`}
style={{ ["--fa-accent" as string]: accent, ["--fa-duration" as string]: `${duration}ms`, ...style }}
onKeyDown={handleKeyDown}
{...props}
>
{items.map((item, index) => {
const isOpen = open.includes(item.id);
const triggerId = `${uid}-trigger-${index}`;
const panelId = `${uid}-panel-${index}`;
// A single, non-collapsible open item cannot be closed: expose that state to AT.
const locked = isOpen && type === "single" && !collapsible;
return (
<div
key={item.id}
data-state={isOpen ? "open" : "closed"}
className={
isCards
? `rounded-xl border bg-white transition-colors duration-200 dark:bg-zinc-900 ${
isOpen
? "border-zinc-900/20 shadow-[0_1px_2px_rgba(0,0,0,0.05)] dark:border-white/20"
: "border-zinc-900/10 dark:border-white/10"
}`
: "border-b border-zinc-900/10 dark:border-white/10"
}
>
<Heading className="m-0">
<button
type="button"
id={triggerId}
data-accordion-trigger={uid}
aria-expanded={isOpen}
aria-controls={panelId}
aria-disabled={locked || undefined}
disabled={item.disabled}
onClick={() => toggle(item.id)}
className={`group flex w-full items-center gap-4 text-left text-[15px] font-medium tracking-tight text-zinc-900 transition-colors hover:text-zinc-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--fa-accent)] disabled:cursor-not-allowed disabled:opacity-40 dark:text-zinc-100 dark:hover:text-white ${
isCards ? "rounded-xl px-4 py-3.5" : "rounded-sm py-4"
}`}
>
{showIndex && (
<span
aria-hidden="true"
className={`w-5 shrink-0 font-mono text-[10px] uppercase tracking-[0.14em] tabular-nums transition-colors ${
isOpen ? "text-[var(--fa-accent)]" : "text-zinc-400 dark:text-zinc-500"
}`}
>
{String(index + 1).padStart(2, "0")}
</span>
)}
<span className="min-w-0 flex-1">{item.title}</span>
<Indicator kind={icon} open={isOpen} />
</button>
</Heading>
{/* Animating grid rows from 0fr to 1fr transitions to the content's natural height. */}
<div
id={panelId}
role="region"
aria-labelledby={triggerId}
// Collapsed content stays in the DOM for the animation but is removed from focus and AT.
inert={!isOpen}
className={`grid transition-[grid-template-rows] duration-[var(--fa-duration)] ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none ${
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
}`}
>
<div className="min-h-0 overflow-hidden">
<div
className={`text-sm leading-relaxed text-zinc-600 transition-[opacity,translate] duration-[var(--fa-duration)] ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none dark:text-zinc-400 ${
isOpen ? "translate-y-0 opacity-100" : "-translate-y-1 opacity-0"
} ${isCards ? "px-4 pb-4" : "pb-5"} ${showIndex ? (isCards ? "pl-[52px]" : "pl-9") : ""}`}
>
{item.content}
</div>
</div>
</div>
</div>
);
})}
</div>
);
}