Accordion
A vertically stacked set of collapsible sections, composed as a Radix-style compound — Root, Item, Trigger, Content. The call-site decides which section is the trigger, which is the body, and what content lives inside each.
An open-source React component library extracted from production projects and lifted to a strict quality bar before shipping.
Customize
Behaviour
Installation
npx shadcn@latest add https://craftbits.dev/r/accordion.jsonUsage
Accordion is a compound — Root is the outer container and owns the open-set state machine, Item is one section with a stable id, Trigger is the clickable header, Content is the animated body.
import { Accordion } from "@craft-bits/core";
<Accordion.Root type="single" collapsible defaultOpenIds={["a"]}>
<Accordion.Item id="a">
<Accordion.Trigger>What is craft-bits?</Accordion.Trigger>
<Accordion.Content>An open-source React library.</Accordion.Content>
</Accordion.Item>
<Accordion.Item id="b">
<Accordion.Trigger>How do I install?</Accordion.Trigger>
<Accordion.Content>Use the shadcn CLI.</Accordion.Content>
</Accordion.Item>
</Accordion.Root>Switch to multi-open by passing type="multiple" — every item becomes independently collapsible:
<Accordion.Root type="multiple" defaultOpenIds={["a", "b"]}>
{/* ... */}
</Accordion.Root>Take control of the open set with openIds + onOpenIdsChange:
"use client";
import { useState } from "react";
import { Accordion } from "@craft-bits/core";
const [openIds, setOpenIds] = useState<readonly string[]>(["a"]);
<Accordion.Root type="single" openIds={openIds} onOpenIdsChange={setOpenIds}>
{/* ... */}
</Accordion.Root>Understanding the component
- Compound parts. Root owns the open-set state machine and broadcasts it via context; Item carries the stable id; Trigger renders the clickable button with
aria-expandedandaria-controls; Content mounts only when open and animates height + opacity. Composing rather than passing a flatitemsarray means one section can carry custom triggers, icons, or content layouts without a special prop. - Controlled and uncontrolled. Pass
openIdsfor fully controlled state, ordefaultOpenIdsfor uncontrolled. The component picks the mode from whichever prop is defined on mount, the React tradition forvalue/defaultValuepairs. - Single vs multiple.
type="single"keeps at most one section open;type="multiple"lets any subset stay open. With single,collapsible(default true) controls whether the last open item can be closed by re-tapping its own trigger. - ARIA correctness. Each Trigger is a real
<button type="button">inside an<h3>row, witharia-expandedtracking state andaria-controlspointing at the Content's id. The Content carriesrole="region"andaria-labelledbyback to the Trigger — assistive tech announces "expanded / collapsed" and reads the section heading on focus. - Animated disclosure. The body uses Framer Motion
AnimatePresencewithinitial={false}to skip first-render motion. Height tweens from0toautoand back; the chevron rotates 180° in sync. Motion runs throughSPRINGS.smoothso timing matches the rest of the library. - Focus visible. Triggers get a
focus-visible:ring keyed to--cb-accent, inset so it lands inside the row even on a borderless surface. Keyboard users always see the active section.
Props
Accordion.Root
| Prop | Type | Default | Description |
|---|---|---|---|
type | 'single' | 'multiple' | 'single' | Whether one or many items may be open at once. |
collapsible | boolean | true | Single mode only — whether the open item can be closed by re-tapping it. |
openIds | readonly string[] | — | Controlled set of open item ids. |
defaultOpenIds | readonly string[] | [] | Initial open set — uncontrolled mode only. |
onOpenIdsChange | (ids: readonly string[]) => void | — | Fires on every open-set change in either mode. |
className | string | — | Merged onto the rendered <div>. |
...rest | HTMLAttributes<HTMLDivElement> | — | Any other <div> prop. |
Accordion.Item
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | — | Stable id referenced by the openIds array. Required. |
className | string | — | Merged onto the rendered <div>. |
...rest | HTMLAttributes<HTMLDivElement> | — | Any other <div> prop. |
Accordion.Trigger
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Merged onto the rendered <button>. |
onClick | MouseEventHandler<HTMLButtonElement> | — | Runs before the toggle; call event.preventDefault() to suppress the toggle. |
...rest | ButtonHTMLAttributes<HTMLButtonElement> | — | Any other <button> prop. |
Accordion.Content
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Merged onto the inner <div> (inside the animated wrapper). |
...rest | HTMLAttributes<HTMLDivElement> | — | Any other <div> prop. |
Accessibility
- Each Trigger is a real
<button type="button">so keyboard users get Enter / Space activation for free and screen readers announce it as "button". aria-expandedtracks the open state on the Trigger;aria-controlspoints at the Content's id. Assistive tech reads "expanded" / "collapsed" and can jump to the controlled region.- The Content carries
role="region"andaria-labelledbyback to the Trigger so the region announces with the section's name on focus. - Triggers sit inside an
<h3>row — the heading depth makes the accordion a navigable landmark for screen-reader rotor users. Override the heading semantics withclassNameif you need a different level inside an existing heading hierarchy. - Focus ring uses
--cb-accentand an inset offset so keyboard focus is visible even on a borderless surface. - Motion is short and uses opacity + height, both compositor-friendly. Users with
prefers-reduced-motionstill get the open / closed states — only the spring is muted, never the affordance.
Credits
- Extracted from:
algoflashcards(src/platform/ui/accordion.tsx). The original was a thin wrapper overradix-ui's Accordion primitive. The craft-bits version drops the runtime dependency on radix-accordion and rebuilds the state machine in plain React so the component ships without an extra peer.