Display
Accordion
A zero-dependency accessible disclosure list — single or multiple expansion, grid-rows animation, and full APG keyboard support.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/accordion.jsonPrompt
Build a React + TypeScript + Tailwind "Accordion" component (lucide-react
ChevronDown), zero other runtime dependencies.
Contract
- Export items: { id: string; title: ReactNode; content: ReactNode }[].
- type?: "single" | "multiple" (default "single") — single closes the
previously open item when a new one opens; multiple toggles items
independently.
- defaultOpen?: string[] — item ids expanded on mount.
- className?: string, merged onto the root via cn().
Behavior
- Each item renders an <h3> wrapping a <button> trigger row (title + a
ChevronDown icon that rotates 180deg when open) and a content panel below
it. Clicking a trigger toggles that item's membership in an internal
`open: string[]` state array — in "single" mode setting it replaces the
array with just that id (or empties it if it was already the only open
item); in "multiple" mode it pushes/removes freely.
- aria: trigger has aria-expanded + aria-controls pointing at the panel id;
panel has role="region" + aria-labelledby pointing at the trigger id.
Ids are namespaced with useId() so multiple Accordion instances on one
page never collide.
- Keyboard: buttons are native, so Tab/Shift+Tab, Enter and Space already
work. Add ArrowDown/ArrowUp to move focus to the next/previous trigger
(wrapping at the ends) and Home/End to jump to the first/last trigger —
the WAI-ARIA APG accordion pattern. Arrow keys only move focus, they
never change what's open.
- Expand animation: the panel wrapper animates grid-template-rows from
0fr to 1fr with an inner overflow-hidden div, so height animates without
measuring pixels; instant under prefers-reduced-motion instead of
animating.
Rendering & styling
- Semantic tokens only: text-muted-foreground for the chevron and content
text, focus-visible:ring-ring for the focus ring, border-b between items.
cn() merges the consumer className into the root.
- Trigger row: py-4 text-sm font-medium, hover:underline (shadcn's
disclosure-row convention), chevron shrink-0 with a 200ms transform
transition (motion-reduce:transition-none).
- Content: text-sm text-muted-foreground pb-4, so it sits flush under the
trigger once expanded.
Customization levers
- Icon: swap ChevronDown for another lucide icon, or move it to the left
of the title — it's one JSX node, not baked into the toggle logic.
- Visual style: drop the border-b divider list for individually rounded
bg-card items (a "boxed" accordion) by moving the border from the row
wrapper to each item's own container.
- Controlled usage: lift `open` out as value/onValueChange props instead of
internal state when a parent needs to read or drive which items are
expanded (e.g. syncing with a URL hash).
- Density: the py-4 row padding and pb-4 content padding are the only two
spacing knobs — tighten both together for compact settings panels.Concepts
- Disclosure pattern — a header hides or reveals its own content block; the accordion is just a list of these paired with a rule for how many stay open at once.
- Single vs multiple expansion —
typeswaps that rule: "single" replaces the open-id array on click (exclusive), "multiple" toggles membership freely (independent). - grid-template-rows animation — animating
0fr → 1fron the panel wrapper (with an inneroverflow-hidden) expands to the content's natural height without ever measuring pixels in JS, and collapses to an instant snap underprefers-reduced-motion. - APG keyboard navigation — Arrow keys move focus between trigger buttons (wrapping at the ends), Home/End jump to the first/last — they never toggle content, matching the WAI-ARIA Accordion pattern instead of a custom scheme.
- Collision-safe ids —
useId()prefixes every trigger/panel id pair, so dropping two<Accordion>instances on the same page never produces duplicate DOM ids foraria-controls/aria-labelledby.