## Accordion — Navigation height auto, done correctly. Docs: https://www.interior.dev/docs/accordion Reference: https://www.interior.dev/reference/accordion License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT) ### Install Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project. `bunx shadcn@latest add https://www.interior.dev/r/accordion.json` Or run `bun add motion` and copy the source below. ### Usage ```tsx "use client"; import { useState } from "react"; import { Accordion } from "@/components/interior/accordion"; export function BillingFaq() { const [open, setOpen] = useState(["invoices"]); return ( Every invoice is emailed to the billing contact and mirrored under Settings, Billing, History.

), }, { id: "seats", title: "How are seats counted?", content: (

Seats are counted at the end of the cycle. Removing a member frees the seat immediately.

), }, { id: "tax", title: "Tax documents", content:

Available after one paid cycle.

, }, ]} /> ); } ``` ### Props - `items` (`readonly AccordionItem[]`) Rows to render. Each is { id, title, content, meta? }; id must be stable because it keys both the open set and the aria wiring. - `type` (`"single" | "multiple"`) — default: `"single"` Whether opening a row closes its siblings or leaves them alone. - `defaultOpen` (`readonly string[]`) — default: `[]` Uncontrolled starting open set. In single mode only the first id is honoured. - `open` (`readonly string[]`) Controlled open set. Supplying it makes the parent the source of truth. - `onOpenChange` (`(open: string[]) => void`) Fires with the next open set on every toggle, controlled or not. - `collapsible` (`boolean`) — default: `true` In single mode, whether the open row can be clicked shut leaving nothing open. - `maxPanelHeight` (`number`) — default: `220` Ceiling in pixels for a panel. Content past it scrolls inside the panel with overscroll contained. - `headingLevel` (`number`) — default: `3` aria-level for the header wrapper, so the accordion slots into the surrounding document outline. - `className` (`string`) — default: `""` Appended last to the outer frame, so callers can override the border, surface and radius. ### Behavior notes - Panel height is measured from the panel's own box with a ResizeObserver rather than read once on open, so a panel that rewraps on resize, or grows when a font or image lands, never animates to a stale target. - The first paint renders closed panels at zero and open panels at auto and only starts animating once a real measurement exists, so nothing flashes open and snaps shut on hydration. - A closed panel is inert and aria-hidden, so Tab never lands on a link inside a section nobody can see and a screen reader never reads it out. - Height is capped and the overflow scrolls inside the cap, so a panel holding two hundred rows cannot animate to an unbounded height or push the rest of the page off screen. - The height spring is interruptible in both directions: toggling mid-flight, or growing the content while the panel is open, retargets from the current height instead of restarting from zero, and opacity finishes first so the reflow is hidden. - Headers are real buttons carrying aria-expanded and aria-controls, with Arrow keys, Home and End moving between them; useAccordion exports that state and keyboard behaviour, and useAutoHeight exports the measurement alone, for surfaces you want to draw yourself. ### Source (`components/interior/accordion.tsx`) ```tsx "use client"; import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { motion, useReducedMotion } from "motion/react"; const EASE = [0.23, 1, 0.32, 1] as const; const EXIT_EASE = [0.4, 0, 1, 1] as const; const DISCLOSE = { type: "spring", stiffness: 480, damping: 40, mass: 0.6, } as const; const CHEVRON = { type: "spring", stiffness: 700, damping: 46, mass: 0.5, } as const; const NONE: readonly string[] = []; const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; type Inertable = HTMLElement & { inert?: boolean }; export type UseAutoHeightResult = { ref: React.RefObject; height: number; ready: boolean; }; export function useAutoHeight(): UseAutoHeightResult { const ref = useRef(null); const [height, setHeight] = useState(0); const [ready, setReady] = useState(false); useIsomorphicLayoutEffect(() => { const el = ref.current; if (!el) return; const read = () => { const next = el.getBoundingClientRect().height; setHeight((prev) => (Math.abs(prev - next) < 0.5 ? prev : next)); }; read(); setReady(true); const observer = new ResizeObserver(read); observer.observe(el); return () => observer.disconnect(); }, []); return { ref, height, ready }; } export type AccordionEntry = { id: string; }; export type AccordionHeaderProps = { id: string; ref: (node: HTMLButtonElement | null) => void; type: "button"; onClick: () => void; onKeyDown: (event: React.KeyboardEvent) => void; "aria-expanded": boolean; "aria-controls": string; }; export type AccordionPanelProps = { id: string; role: "region"; "aria-labelledby": string; "aria-hidden": true | undefined; }; export type UseAccordionOptions = { items: readonly AccordionEntry[]; type?: "single" | "multiple"; defaultOpen?: readonly string[]; open?: readonly string[]; onOpenChange?: (open: string[]) => void; collapsible?: boolean; }; export type UseAccordionResult = { open: string[]; isOpen: (id: string) => boolean; toggle: (id: string) => void; headerProps: (id: string) => AccordionHeaderProps; panelProps: (id: string) => AccordionPanelProps; }; export function useAccordion({ items, type = "single", defaultOpen = NONE, open: controlled, onOpenChange, collapsible = true, }: UseAccordionOptions): UseAccordionResult { const base = useId(); const [uncontrolled, setUncontrolled] = useState(() => type === "single" ? defaultOpen.slice(0, 1) : defaultOpen.slice(), ); const open = useMemo( () => (controlled ? controlled.slice() : uncontrolled), [controlled, uncontrolled], ); const headers = useRef(new Map()); const binders = useRef(new Map()); const headerRef = useCallback((id: string): AccordionHeaderProps["ref"] => { const cached = binders.current.get(id); if (cached) return cached; const bind = (node: HTMLButtonElement | null) => { if (node) headers.current.set(id, node); else headers.current.delete(id); }; binders.current.set(id, bind); return bind; }, []); const changed = useRef(onOpenChange); changed.current = onOpenChange; const commit = useCallback((next: string[]) => { setUncontrolled(next); changed.current?.(next); }, []); const isOpen = useCallback((id: string) => open.includes(id), [open]); const toggle = useCallback( (id: string) => { const active = open.includes(id); if (active && !collapsible && type === "single") return; if (type === "single") { commit(active ? [] : [id]); return; } commit(active ? open.filter((x) => x !== id) : [...open, id]); }, [open, type, collapsible, commit], ); const order = useMemo(() => items.map((item) => item.id), [items]); const move = useCallback( (id: string, delta: number, edge: "first" | "last" | null) => { if (order.length === 0) return; const at = order.indexOf(id); if (at < 0) return; const next = edge === "first" ? 0 : edge === "last" ? order.length - 1 : (at + delta + order.length) % order.length; headers.current.get(order[next])?.focus(); }, [order], ); const headerProps = useCallback( (id: string): AccordionHeaderProps => ({ id: `${base}-header-${id}`, ref: headerRef(id), type: "button", onClick: () => toggle(id), onKeyDown: (event: React.KeyboardEvent) => { if (event.key === "ArrowDown") { event.preventDefault(); move(id, 1, null); } else if (event.key === "ArrowUp") { event.preventDefault(); move(id, -1, null); } else if (event.key === "Home") { event.preventDefault(); move(id, 0, "first"); } else if (event.key === "End") { event.preventDefault(); move(id, 0, "last"); } }, "aria-expanded": open.includes(id), "aria-controls": `${base}-panel-${id}`, }), [base, open, toggle, move, headerRef], ); const panelProps = useCallback( (id: string): AccordionPanelProps => ({ id: `${base}-panel-${id}`, role: "region", "aria-labelledby": `${base}-header-${id}`, "aria-hidden": open.includes(id) ? undefined : true, }), [base, open], ); return { open, isOpen, toggle, headerProps, panelProps }; } export type AccordionItem = { id: string; title: React.ReactNode; content: React.ReactNode; meta?: React.ReactNode; }; export type AccordionProps = { items: readonly AccordionItem[]; type?: "single" | "multiple"; defaultOpen?: readonly string[]; open?: readonly string[]; onOpenChange?: (open: string[]) => void; collapsible?: boolean; maxPanelHeight?: number; headingLevel?: number; className?: string; }; export function Accordion({ items, type = "single", defaultOpen = NONE, open: controlled, onOpenChange, collapsible = true, maxPanelHeight = 220, headingLevel = 3, className = "", }: AccordionProps) { const reduced = useReducedMotion(); const entries = useMemo(() => items.map(({ id }) => ({ id })), [items]); const { isOpen, headerProps, panelProps } = useAccordion({ items: entries, type, defaultOpen, open: controlled, onOpenChange, collapsible, }); return (
{items.map((item) => ( ))}
); } function AccordionRow({ item, open, reduced, maxPanelHeight, headingLevel, header, panel, }: { item: AccordionItem; open: boolean; reduced: boolean; maxPanelHeight: number; headingLevel: number; header: AccordionHeaderProps; panel: AccordionPanelProps; }) { const { ref, height, ready } = useAutoHeight(); useEffect(() => { const el = ref.current as Inertable | null; if (!el) return; el.inert = !open; return () => { el.inert = false; }; }, [ref, open]); return (
{item.content}
); } ```