react-collapsible/Examples/FAQ accordion
ExamplesPattern

FAQ accordion

Four questions, one open at a time. A single piece of state decides which panel is expanded; the chevron, the fade, and the collapse all react to it. The answers are real, too — they cover how the library animates, what it runs on, and where collapsed content goes.

Each panel is a CSS Grid container whose row track transitions between 0fr and 1fr. The browser resolves the fr unit against the content, so no JavaScript ever reads a height — the animation is pure CSS.

Chrome 117+, Edge 117+, Safari 17.4+, and Firefox 129+ — the versions that ship @starting-style and transition-behavior: allow-discrete. In older browsers content still opens and closes; it snaps instead of animating.

By default it is display: none — out of the document flow and out of the accessibility tree. Switch to hideMode="hide" to keep it in the DOM with visibility: hidden and aria-hidden, so form state and iframes survive the collapse.

No — it is fully controlled. You pass open as a prop and toggle it however you like. This accordion is one useState holding the id of the open panel; setting it closes the previous panel and opens the next in the same render.

How it's put together

  • One openId, exclusive by construction. State is the id of the open panel, or null. Opening question B sets openId to B's id — A's open prop turns false in the same render, so A animates closed while B animates open. Clicking the open question sets null and closes everything.
  • The chevron keys off aria-expanded. Every trigger already carries aria-controls and aria-expanded for the accessibility contract. Rotation styles against that attribute (group-aria-expanded:rotate-180) — no separate rotation state, nothing to drift out of sync.
  • Stable, prefixed ids. Each id (faq-accordion-animation, …) is both the panel's DOM id and its trigger's aria-controls value. Deriving ids from the data — never from an array index — keeps them stable, and prefixing them with the page slug keeps them unique across the site.
  • The content fades with the collapse. open:opacity-100 is a conditional class: it flips on the same deferred frame as the expansion, so the fade tracks the grid animation. duration-500 matches the stylesheet's default collapse duration.

This page shows the finished pattern; Building an accordion walks through constructing it from a blank file.

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

type Faq = {
id: string
question: string
answer: string
}

const faqs: Faq[] = [
{
  id: 'faq-accordion-animation',
  question: 'How does the animation work without measuring content?',
  answer:
    'Each panel is a CSS Grid container whose row track transitions between 0fr and 1fr. The browser resolves the fr unit against the content, so no JavaScript ever reads a height — the animation is pure CSS.',
},
{
  id: 'faq-accordion-browser-support',
  question: 'Which browsers are supported?',
  answer:
    'Chrome 117+, Edge 117+, Safari 17.4+, and Firefox 129+ — the versions that ship @starting-style and transition-behavior: allow-discrete. In older browsers content still opens and closes; it snaps instead of animating.',
},
{
  id: 'faq-accordion-hide-modes',
  question: 'What happens to collapsed content?',
  answer:
    'By default it is display: none — out of the document flow and out of the accessibility tree. Switch to hideMode="hide" to keep it in the DOM with visibility: hidden and aria-hidden, so form state and iframes survive the collapse.',
},
{
  id: 'faq-accordion-controlled',
  question: 'Does the component manage its own open state?',
  answer:
    'No — it is fully controlled. You pass open as a prop and toggle it however you like. This accordion is one useState holding the id of the open panel; setting it closes the previous panel and opens the next in the same render.',
},
]

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

return (
  <div className='rounded-(--radius-callout) border border-(--color-line) bg-(--color-bg-elev) px-5 py-2'>
    {faqs.map((faq) => {
      const open = openId === faq.id
      return (
        <div key={faq.id} className='border-b border-(--color-line-soft) last:border-b-0'>
          <button
            type='button'
            aria-controls={faq.id}
            aria-expanded={open}
            onClick={() => setOpenId(open ? null : faq.id)}
            className='group flex w-full cursor-pointer items-center justify-between gap-4 py-4 text-left text-[15px] font-medium text-(--color-ink)'
          >
            {faq.question}
            <svg width='16' height='16' viewBox='0 0 16 16' fill='none' aria-hidden='true' className='shrink-0 text-(--color-ink-mute) transition-transform duration-300 group-aria-expanded:rotate-180'>
              <path d='M4 6l4 4 4-4' stroke='currentColor' strokeWidth='1.5' strokeLinecap='round' strokeLinejoin='round' />
            </svg>
          </button>
          <Collapsible id={faq.id} open={open} className='opacity-0 transition-opacity duration-500 open:opacity-100'>
            <p className='pb-4 text-[14px] leading-relaxed text-(--color-ink-soft)'>{faq.answer}</p>
          </Collapsible>
        </div>
      )
    })}
  </div>
)
}