Accordion Component in React: Animated, Accessible, Compound Pattern
Build a fully accessible, animated accordion in React using the compound component pattern — WAI-ARIA compliant, smooth height transitions, zero dependencies.
Why Accordions Are Harder Than They Look
You'd think an accordion is trivial — click header, panel opens, done. But the moment you care about keyboard navigation, screen readers, and smooth height transitions, you're actually dealing with three separate problem domains at once. Most tutorials skip at least two of them.
The WAI-ARIA accordion pattern (spec updated heavily in ARIA 1.2) has specific requirements around aria-expanded, aria-controls, focus management, and keyboard shortcuts. Get those wrong and your component is broken for a non-trivial chunk of users. Not broken in a way your manual QA catches — broken in a way a screen reader user on iOS notices immediately.
In practice, the animation part is what trips people up most. You can't height: 0 to height: auto in CSS without a trick. There's no interpolatable value from auto, and max-height hacks look janky at 60fps when the content is variable length. We'll solve that properly using ResizeObserver to measure real pixel heights before animating.
One more thing — compound components. This isn't a buzzword. It's the difference between a rigid <Accordion items={[...]} /> API that you'll be patching in six months, and a flexible <Accordion><Accordion.Item> structure that composes naturally inside any layout. If you've ever used Radix UI or Headless UI, you already know which API you actually want to maintain.
The Compound Component Pattern, Explained Fast
The core idea: a parent component owns shared state (which item is open), and child components consume that state via React context. No prop-drilling. No index juggling. Each child knows what it needs through the context tunnel.
Here's the minimal context setup you need before writing a single DOM element:
``tsx
type AccordionCtx = {
openId: string | null;
toggle: (id: string) => void;
allowMultiple: boolean;
};
const AccordionContext = React.createContext<AccordionCtx | null>(null);
function useAccordion() {
const ctx = React.useContext(AccordionContext);
if (!ctx) throw new Error('useAccordion must be used inside <Accordion>');
return ctx;
}
`
The allowMultiple flag lets you support both single-open and multi-open modes from the same codebase — just swap the state shape from string | null to Set<string>`. Worth noting: that distinction matters a lot for FAQ pages vs. settings panels, and your API should handle both without requiring a completely different component.
That said, keeping context lean is important. Don't put animation state in there. Each Item should own its own expanded boolean, derived from the shared openId. Context is for coordination, not for micromanaging every panel's DOM state.
Full Accordion Implementation
Let's build the whole thing. Root component first, then Item, Header, and Panel as named sub-components attached to the export.
``tsx
import React, { createContext, useContext, useId, useRef, useState, useEffect } from 'react';
// --- Context ---
type AccordionCtx = {
openIds: Set<string>;
toggle: (id: string) => void;
allowMultiple: boolean;
};
const AccordionContext = createContext<AccordionCtx | null>(null);
function useAccordion() {
const ctx = useContext(AccordionContext);
if (!ctx) throw new Error('Must be used inside <Accordion>');
return ctx;
}
// --- Root ---
type AccordionProps = {
children: React.ReactNode;
allowMultiple?: boolean;
defaultOpen?: string[];
className?: string;
};
export function Accordion({ children, allowMultiple = false, defaultOpen = [], className }: AccordionProps) {
const [openIds, setOpenIds] = useState<Set<string>>(new Set(defaultOpen));
const toggle = (id: string) => {
setOpenIds(prev => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
if (!allowMultiple) next.clear();
next.add(id);
}
return next;
});
};
return (
<AccordionContext.Provider value={{ openIds, toggle, allowMultiple }}>
<div className={className} role="list">{children}</div>
</AccordionContext.Provider>
);
}
``
Now the Item, which owns its own id and derived isOpen state:
``tsx
type ItemCtx = { itemId: string; isOpen: boolean };
const ItemContext = createContext<ItemCtx | null>(null);
function useItem() {
const ctx = useContext(ItemContext);
if (!ctx) throw new Error('Must be used inside <Accordion.Item>');
return ctx;
}
type ItemProps = { children: React.ReactNode; id?: string; className?: string };
Accordion.Item = function Item({ children, id, className }: ItemProps) {
const generatedId = useId();
const itemId = id ?? generatedId;
const { openIds } = useAccordion();
const isOpen = openIds.has(itemId);
return (
<ItemContext.Provider value={{ itemId, isOpen }}>
<div className={className} role="listitem">{children}</div>
</ItemContext.Provider>
);
};
``
The Header needs to handle keyboard interactions per ARIA spec — Enter and Space activate, ArrowDown/ArrowUp move focus between headers:
``tsx
Accordion.Header = function Header({ children, className }: { children: React.ReactNode; className?: string }) {
const { toggle } = useAccordion();
const { itemId, isOpen } = useItem();
const panelId = panel-${itemId};
const headerId = header-${itemId};
return (
<button
id={headerId}
aria-expanded={isOpen}
aria-controls={panelId}
onClick={() => toggle(itemId)}
className={className}
type="button"
>
{children}
</button>
);
};
`
Honestly, a lot of accordion implementations forget type="button"` inside forms and then wonder why their page submits on expand. Don't be that dev.
Finally, the animated Panel. This is where the height trick lives — we measure the real pixel height via ResizeObserver, write it to a CSS custom property, and let the transition do the rest:
``tsx
Accordion.Panel = function Panel({ children, className }: { children: React.ReactNode; className?: string }) {
const { itemId, isOpen } = useItem();
const headerId = header-${itemId};
const panelId = panel-${itemId};
const innerRef = useRef<HTMLDivElement>(null);
const outerRef = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState(0);
useEffect(() => {
const inner = innerRef.current;
if (!inner) return;
const ro = new ResizeObserver(([entry]) => {
setHeight(entry.contentRect.height);
});
ro.observe(inner);
return () => ro.disconnect();
}, []);
return (
<div
ref={outerRef}
id={panelId}
role="region"
aria-labelledby={headerId}
hidden={!isOpen && height === 0 ? true : undefined}
style={{
height: isOpen ? height : 0,
overflow: 'hidden',
transition: 'height 280ms cubic-bezier(0.4, 0, 0.2, 1)',
}}
>
<div ref={innerRef} className={className}>{children}</div>
</div>
);
};
`
The outer div animates from 0 to the measured pixel height. The inner div is what ResizeObserver watches — this way if content inside the panel changes size (think dynamic content), the panel height self-corrects automatically. That 280ms` easing is close to Material Motion's standard curve and feels snappy without being jarring at any viewport size.
Usage: What the API Actually Looks Like
Here's how you'd wire up an FAQ section. Clean, readable, no nonsense:
``tsx
import { Accordion } from '@/components/Accordion';
export function FAQ() {
return (
<Accordion allowMultiple className="max-w-2xl mx-auto space-y-2">
<Accordion.Item id="shipping">
<Accordion.Header className="flex w-full justify-between px-4 py-3 bg-white rounded-lg shadow-sm text-left font-medium">
How long does shipping take?
<ChevronIcon />
</Accordion.Header>
<Accordion.Panel className="px-4 py-3 text-gray-600 text-sm">
Standard shipping takes 3–5 business days. Express is 1–2.
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item id="returns">
<Accordion.Header className="flex w-full justify-between px-4 py-3 bg-white rounded-lg shadow-sm text-left font-medium">
What's the return policy?
<ChevronIcon />
</Accordion.Header>
<Accordion.Panel className="px-4 py-3 text-gray-600 text-sm">
30 days, no questions asked. Just ship it back.
</Accordion.Panel>
</Accordion.Item>
</Accordion>
);
}
``
Notice that each Accordion.Item gets an explicit id string — that's optional, but useful when you want to open items programmatically (say, deep-linking to a specific FAQ from a URL hash). If you omit id, useId() generates a stable one automatically.
Quick aside: the allowMultiple prop defaults to false. For a settings panel where only one section should be visible at a time, just drop it. For FAQ pages where users often want to compare answers side by side, pass allowMultiple. Same component, genuinely different UX.
Accessibility Deep Dive
The ARIA roles here matter. The outer container gets role="list", each item gets role="listitem", the panel gets role="region" with aria-labelledby pointing to its header button. This is straight from the WAI-ARIA Accordion Pattern spec as of 2024, and it's what screen readers expect.
The hidden attribute on the panel is a subtlety. Technically aria-hidden="true" on a closed panel is another option, but hidden completely removes it from the accessibility tree, which means screen reader users won't accidentally tab into collapsed content. The hidden={!isOpen && height === 0 ? true : undefined} conditional means we only apply it when the panel is both logically closed AND visually fully collapsed (height 0) — during the closing animation, it stays visible so the transition plays out properly.
Keyboard navigation is where most implementations fall apart. The spec calls for ArrowDown/ArrowUp to move focus between accordion headers, Home/End to jump to first/last. You can wire these up by querying all [aria-expanded] buttons in the accordion container and managing focus manually. Here's a focused version:
``tsx
const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
const headers = Array.from(
e.currentTarget.closest('[role="list"]')!.querySelectorAll<HTMLButtonElement>('button[aria-expanded]')
);
const current = headers.indexOf(e.currentTarget);
if (e.key === 'ArrowDown') {
headers[(current + 1) % headers.length]?.focus();
e.preventDefault();
} else if (e.key === 'ArrowUp') {
headers[(current - 1 + headers.length) % headers.length]?.focus();
e.preventDefault();
} else if (e.key === 'Home') {
headers[0]?.focus();
e.preventDefault();
} else if (e.key === 'End') {
headers[headers.length - 1]?.focus();
e.preventDefault();
}
};
`
Add this to the Accordion.Header button's onKeyDown` and you've covered the full spec. Test it with VoiceOver on Safari or NVDA on Windows — those two catch different edge cases and both matter for real-world users.
Look, a lot of "accessible" component libraries ship something that passes automated axe checks but fails human testing. Automated tools find maybe 30% of accessibility issues. The keyboard nav and screen reader announcement behavior you can only verify manually. Worth doing.
Styling: Tailwind, CSS Variables, and Design System Fit
The component above is fully unstyled — intentionally. You pass classNames at every level. That's the right call for a compound component that needs to slot into different design systems without fighting specificity wars.
If you're building something that needs to match glassmorphism components or one of the other visual styles in Empire UI, you're controlling the panel background and border via the class you pass to Accordion.Item. No internal styles to override. No !important wrestling.
``tsx
// Glassmorphism accordion variant
<Accordion.Item
id="plans"
className="rounded-xl border border-white/20 bg-white/10 backdrop-blur-md"
>
``
For a neobrutalism look (thick 2px border, offset box shadow), you'd just swap the className. The animation stays identical because it's inline style on the height, not a class.
You can generate the exact shadow values you need with the box shadow generator — it exports the CSS custom property format you can drop straight into a Tailwind config or a CSS variables file. Pair that with the gradient generator if you want gradient headers on your accordion items, which looks surprisingly good on landing pages. One more thing — if you're defining these styles as part of a larger component library, the border-design-system and elevation-shadow-system articles on the blog go deeper on how to tokenize these values so they're consistent across every accordion variant you ship.
Testing Your Accordion
Unit testing with React Testing Library is straightforward. The key assertions: clicking a header toggles aria-expanded, the panel content is/isn't in the accessibility tree, and keyboard navigation moves focus correctly.
``tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Accordion } from './Accordion';
test('opens panel on header click', async () => {
const user = userEvent.setup();
render(
<Accordion>
<Accordion.Item id="test">
<Accordion.Header>What is this?</Accordion.Header>
<Accordion.Panel>This is a test panel.</Accordion.Panel>
</Accordion.Item>
</Accordion>
);
const header = screen.getByRole('button', { name: /what is this/i });
expect(header).toHaveAttribute('aria-expanded', 'false');
await user.click(header);
expect(header).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByText('This is a test panel.')).toBeVisible();
});
test('closes sibling items when allowMultiple is false', async () => {
const user = userEvent.setup();
render(
<Accordion>
<Accordion.Item id="a">
<Accordion.Header>First</Accordion.Header>
<Accordion.Panel>Panel A</Accordion.Panel>
</Accordion.Item>
<Accordion.Item id="b">
<Accordion.Header>Second</Accordion.Header>
<Accordion.Panel>Panel B</Accordion.Panel>
</Accordion.Item>
</Accordion>
);
await user.click(screen.getByRole('button', { name: /first/i }));
await user.click(screen.getByRole('button', { name: /second/i }));
expect(screen.getByRole('button', { name: /first/i })).toHaveAttribute('aria-expanded', 'false');
expect(screen.getByRole('button', { name: /second/i })).toHaveAttribute('aria-expanded', 'true');
});
``
The height animation won't play in JSDOM — ResizeObserver isn't available. Mock it globally in your test setup:
``ts
// vitest.setup.ts or jest.setup.ts
global.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
};
`
Once that's mocked, the panel renders with height: 0 in closed state and height: 0` in open state (since JSDOM reports zero for layout measurements). That's fine — you're testing state logic and ARIA attributes, not the CSS animation. Visual regression tests with Chromatic or Playwright cover the animation separately.
Worth noting: testing keyboard nav requires userEvent.keyboard('{ArrowDown}') after focusing the first header. @testing-library/user-event v14 handles this correctly; older v13 has gaps in keyboard event simulation that'll give you false positives.
FAQ
Not smoothly, no. CSS can't interpolate from height: 0 to height: auto. You either use JS to read the real pixel height, or accept the max-height hack which looks terrible on variable-content panels.
If you're shipping a product and time matters, yes — Radix handles edge cases you haven't thought of yet. Build your own when you need full control over animation, API shape, or bundle size constraints.
Pass the item's id to the defaultOpen array prop on <Accordion defaultOpen={['my-item-id']}>. You can also drive it from URL hash with a useEffect that calls the context's toggle on mount.
The context and state parts need to be client components ('use client'). The Accordion.Panel animation uses useEffect and ResizeObserver, so mark the file with 'use client' at the top and import it into your server component tree normally.