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.json

Usage

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

  1. 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-expanded and aria-controls; Content mounts only when open and animates height + opacity. Composing rather than passing a flat items array means one section can carry custom triggers, icons, or content layouts without a special prop.
  2. Controlled and uncontrolled. Pass openIds for fully controlled state, or defaultOpenIds for uncontrolled. The component picks the mode from whichever prop is defined on mount, the React tradition for value / defaultValue pairs.
  3. 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.
  4. ARIA correctness. Each Trigger is a real <button type="button"> inside an <h3> row, with aria-expanded tracking state and aria-controls pointing at the Content's id. The Content carries role="region" and aria-labelledby back to the Trigger — assistive tech announces "expanded / collapsed" and reads the section heading on focus.
  5. Animated disclosure. The body uses Framer Motion AnimatePresence with initial={false} to skip first-render motion. Height tweens from 0 to auto and back; the chevron rotates 180° in sync. Motion runs through SPRINGS.smooth so timing matches the rest of the library.
  6. 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

PropTypeDefaultDescription
type'single' | 'multiple''single'Whether one or many items may be open at once.
collapsiblebooleantrueSingle mode only — whether the open item can be closed by re-tapping it.
openIdsreadonly string[]Controlled set of open item ids.
defaultOpenIdsreadonly string[][]Initial open set — uncontrolled mode only.
onOpenIdsChange(ids: readonly string[]) => voidFires on every open-set change in either mode.
classNamestringMerged onto the rendered <div>.
...restHTMLAttributes<HTMLDivElement>Any other <div> prop.

Accordion.Item

PropTypeDefaultDescription
idstringStable id referenced by the openIds array. Required.
classNamestringMerged onto the rendered <div>.
...restHTMLAttributes<HTMLDivElement>Any other <div> prop.

Accordion.Trigger

PropTypeDefaultDescription
classNamestringMerged onto the rendered <button>.
onClickMouseEventHandler<HTMLButtonElement>Runs before the toggle; call event.preventDefault() to suppress the toggle.
...restButtonHTMLAttributes<HTMLButtonElement>Any other <button> prop.

Accordion.Content

PropTypeDefaultDescription
classNamestringMerged onto the inner <div> (inside the animated wrapper).
...restHTMLAttributes<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-expanded tracks the open state on the Trigger; aria-controls points at the Content's id. Assistive tech reads "expanded" / "collapsed" and can jump to the controlled region.
  • The Content carries role="region" and aria-labelledby back 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 with className if you need a different level inside an existing heading hierarchy.
  • Focus ring uses --cb-accent and 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-motion still 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 over radix-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.