react-collapsible/Guides/Building an accordion
Guides~5 min

Building an accordion

The accordion — N panels, at most one open — is the most common collapsible composite, and react-collapsible deliberately doesn't ship one. Collapsible is controlled: it renders whatever open says and nothing more, so an accordion is one useState holding the id of the open item. Each trigger writes it, each panel compares against it, and exclusivity falls out of the comparison.

No accordion component, on purpose

Libraries whose panels own their open state need an Accordion wrapper, because something has to reach into the panels and close the others. Here there's nothing to reach into: a panel is open exactly when its id matches openId, and one variable can only hold one id — at-most-one-open is enforced by construction.

The pattern

Three pieces: an array of items, one state variable, one map.

TSXfaq.tsx
import { Collapsible } from '@westopp/react-collapsible'
import { useState } from 'react'

const items = [
{ id: 'building-an-accordion-shipping', title: 'Shipping', body: 'Orders leave the warehouse within 48 hours.' },
{ id: 'building-an-accordion-returns', title: 'Returns', body: 'Thirty days, no questions, prepaid label included.' },
{ id: 'building-an-accordion-warranty', title: 'Warranty', body: 'Two years on every part we manufacture.' },
]

export const Faq = () => {
const [openId, setOpenId] = useState<string | null>(null)

return (
  <div>
    {items.map((item) => (
      <div key={item.id}>
        <button
          type="button"
          aria-controls={item.id}
          aria-expanded={openId === item.id}
          onClick={() => setOpenId(openId === item.id ? null : item.id)}
        >
          {item.title}
        </button>
        <Collapsible id={item.id} open={openId === item.id}>
          <p>{item.body}</p>
        </Collapsible>
      </div>
    ))}
  </div>
)
}

Three details carry the pattern.

Ids come from the data, prefixed. id lands on a real DOM element, so it must be unique across the whole document — and the trigger's aria-controls resolves it by document lookup. Derive each id from the item plus a page-level prefix (building-an-accordion-shipping) and a second accordion elsewhere on the page can never collide with this one.

Every trigger carries the a11y pair. aria-controls names the panel it drives; aria-expanded mirrors the same comparison the panel uses. That's the trigger contract — in dev builds, a Collapsible that opens without a matching aria-controls element warns in the console.

The toggle closes too. openId === item.id ? null : item.id reads: clicking the open item's trigger resets to null and everything closes; clicking any other item moves the open slot there. If you want one item open on first paint instead, seed the state with its id and give that item's Collapsible initialOpen so it renders open without replaying the entry animation — see controlled state.

Here it is live — same structure, with the panel copy narrating the mechanics:

This whole accordion is a single useState holding the id of the open item, or null. Every trigger writes to it; every Collapsible compares against it.

Opening this item closed the previous one — not because anything coordinates the panels, but because the previous Collapsible stopped matching openId and received open as false.

Click this trigger again and openId resets to null. Every comparison fails, and all three panels are closed.

Letting several stay open

Sometimes exclusivity is wrong — an FAQ where readers compare answers side by side, a settings page with independent sections. The change is the state's shape: a Set of ids instead of a single id. Three lines move.

TSXfaq-multi.tsx
const [openIds, setOpenIds] = useState<Set<string>>(new Set())

const toggle = (id: string) =>
setOpenIds((prev) => {
  const next = new Set(prev)
  if (next.has(id)) next.delete(id)
  else next.add(id)
  return next
})

// ...and in the map, both comparisons become membership checks:
// open={openIds.has(item.id)}      aria-expanded={openIds.has(item.id)}
// onClick={() => toggle(item.id)}

The items array, the ids, the a11y pair — all untouched. The accordion's behavior was never in the markup; it was in the shape of the state.

Keep keys and ids stable

Both the React key and the Collapsible id must survive re-renders unchanged, and both should come from the item's identity — the same field, ideally.

A changed key remounts the Collapsible: the close animation never plays, and the entry animation replays from zero on the new element. Index keys do exactly this whenever items reorder or get filtered. A changed id breaks everything pointing at the old one: openId no longer matches any panel, so the open one closes; a trigger still naming the old id loses its aria-controls association; CSS targeting the id stops matching. Minting ids at render time — random values, counters — does this on every render.