## 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.title}
{item.meta ? (
{item.meta}
) : null}
{item.content}
);
}
```