Accordion
An accordion is a vertically stacked set of sections whose headers reveal or hide related content.
User activates a header
↓
Update the open section state
↓
Expose or hide its panel
↓
Update the header's expanded state
Accordions are commonly used for FAQs, settings, course modules, filters, and compact supporting information.
Core idea: An accordion connects each interactive header to one content panel and keeps their visible and accessible states synchronized.
1. Accordion Anatomy
Every accordion item has two important parts.
Accordion
├── Header button: “Shipping information”
│ └── expanded or collapsed state
└── Panel
└── content controlled by that button
| Part | Responsibility |
|---|---|
| Header | Names the section and toggles it |
| Panel | Contains the content associated with the header |
| Indicator | Visually communicates open or closed state |
| Accordion group | Coordinates single-open or multiple-open behavior |
The whole header should usually be clickable, not only a small chevron icon.
2. When to Use an Accordion
Use an accordion when:
- users need only a few sections at a time;
- headings make hidden content predictable;
- showing everything would create unnecessary scrolling or clutter;
- the content remains understandable when sections are independently collapsed.
Avoid or reconsider it when:
- users must compare content across sections;
- most users need every section;
- essential information may be overlooked when collapsed;
- there are only one or two short pieces of content;
- the interaction is actually navigation rather than disclosure.
Accordions reduce visible content, but they also hide information and add interaction cost. Compactness alone does not make them the right pattern.
3. Accordion vs Similar Patterns
| Pattern | Purpose |
|---|---|
| Accordion | Show or hide vertically stacked content sections |
| Disclosure | Show or hide one independent piece of content |
| Tabs | Switch between peer views inside one content area |
| Dropdown menu | Present actions or navigation choices |
| Tree view | Navigate hierarchical items with nested branches |
Do not give an accordion tab roles. Tabs and accordions have different semantics and keyboard expectations.
4. Start with Native HTML
For a simple disclosure, <details> and <summary> provide open/close behavior and keyboard interaction without React state.
<details>
<summary>What is the return policy?</summary>
<p>Items can be returned within 30 days of delivery.</p>
</details>
Multiple native disclosures can remain open independently:
<details>
<summary>Shipping</summary>
<p>Standard delivery takes three to five working days.</p>
</details>
<details>
<summary>Returns</summary>
<p>Returns are accepted within 30 days.</p>
</details>
Modern browsers also support exclusive groups through the same name:
<details name="product-help">
<summary>Shipping</summary>
<p>Standard delivery takes three to five working days.</p>
</details>
<details name="product-help">
<summary>Returns</summary>
<p>Returns are accepted within 30 days.</p>
</details>
Only one grouped <details> element can be open at a time.
Use native HTML when it satisfies the design. Build a custom component when the application needs controlled state, design-system integration, analytics, coordinated behavior, or more specialized content lifecycle rules.
5. Choose the State Model
There are two common accordion behaviors.
Single-Open Accordion
Only one panel can be open.
const [openId, setOpenId] = useState(null);
function toggle(id) {
setOpenId(current => (current === id ? null : id));
}
openId = null → every panel is closed
openId = "shipping" → only Shipping is open
Multiple-Open Accordion
Several panels can be open together.
const [openIds, setOpenIds] = useState(() => new Set());
function toggle(id) {
setOpenIds(current => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
Do not mutate the existing Set directly. React state should receive a new collection so the update is observable and predictable.
6. Use Stable IDs, Not Array Positions
Accordion data should include stable identifiers.
const faqItems = [
{
id: "shipping",
title: "How long does shipping take?",
content: "Standard delivery takes three to five working days.",
},
{
id: "returns",
title: "Can I return an item?",
content: "Yes, within 30 days of delivery.",
},
];
Using an array index for both state and React's key can attach open state to the wrong item after insertion, deletion, filtering, or reordering.
// Prefer a stable ID from the data.
<AccordionItem key={item.id} id={item.id} />
7. Accessible React Implementation
This version supports either single-open or multiple-open behavior.
import { useState } from "react";
export default function Accordion({
items,
allowMultiple = false,
defaultOpenIds = [],
}) {
const [openIds, setOpenIds] = useState(
() => new Set(defaultOpenIds)
);
function toggleItem(id) {
setOpenIds(current => {
const isOpen = current.has(id);
const next = allowMultiple ? new Set(current) : new Set();
if (isOpen) next.delete(id);
else next.add(id);
return next;
});
}
return (
<div className="accordion">
{items.map(item => (
<AccordionItem
key={item.id}
item={item}
isOpen={openIds.has(item.id)}
onToggle={() => toggleItem(item.id)}
/>
))}
</div>
);
}
function AccordionItem({ item, isOpen, onToggle }) {
const headerId = `accordion-header-${item.id}`;
const panelId = `accordion-panel-${item.id}`;
return (
<section className="accordion__item">
<h3 className="accordion__heading">
<button
id={headerId}
type="button"
aria-expanded={isOpen}
aria-controls={panelId}
className="accordion__trigger"
onClick={onToggle}
>
<span>{item.title}</span>
<span aria-hidden="true">{isOpen ? "−" : "+"}</span>
</button>
</h3>
<div
id={panelId}
role="region"
aria-labelledby={headerId}
hidden={!isOpen}
className="accordion__panel"
>
<p>{item.content}</p>
</div>
</section>
);
}
Why These Attributes Matter
| Attribute | Purpose |
|---|---|
type="button" | Prevents an accordion inside a form from submitting it |
aria-expanded | Announces the current open or closed state |
aria-controls | Identifies the panel controlled by the button |
aria-labelledby | Gives the panel the header's accessible name |
hidden | Removes collapsed content from display and interaction |
Use role="region" selectively. Adding it to many simultaneously expandable panels can create too many page landmarks.
8. Keyboard Interaction
A native <button> already supports the essential behavior.
| Key | Behavior |
|---|---|
Tab | Moves focus to the next focusable control |
Shift + Tab | Moves focus to the previous focusable control |
Enter | Toggles the focused accordion button |
Space | Toggles the focused accordion button |
Do not replace the button with a clickable <div>. A <div> does not automatically provide button semantics, keyboard activation, focus behavior, or disabled behavior.
Some design systems add arrow-key navigation between headers. If added, it must be implemented and tested consistently; it is not necessary for a basic accordion whose headers are ordinary buttons in the tab order.
9. Controlled vs Uncontrolled Accordion
Uncontrolled
The accordion owns its open state.
<Accordion items={faqItems} defaultOpenIds={["shipping"]} />
This is suitable when no parent feature needs to control the selected section.
Controlled
The parent owns the state and passes it down.
<Accordion
items={faqItems}
openIds={openIds}
onOpenChange={setOpenIds}
/>
Controlled state is useful when:
- the URL opens a specific section;
- validation opens a section containing an error;
- another control expands or collapses sections;
- analytics or application rules depend on changes.
Choose one ownership model and document it clearly. Switching between controlled and uncontrolled behavior during a component's lifetime creates confusing state.
10. Preserve or Reset Panel Content
There are two ways to hide a panel.
Keep It Mounted
<div hidden={!isOpen}>{children}</div>
Local form input, media state, and child component state are preserved while the panel is closed.
Unmount It
{isOpen && <div>{children}</div>}
The component is removed and its local state resets. This may save work for expensive panels, but reopening can repeat initialization or data fetching.
This is a product and lifecycle decision, not only a rendering preference.
11. Animation
Animating from height: 0 to height: auto is not a reliable simple CSS transition. Common approaches include:
- animating a measured pixel height;
- using a grid-row expansion technique;
- using the Web Animations API;
- using an established motion component from the design system.
Regardless of the technique:
- keep duration short;
- avoid animating expensive layout repeatedly;
- prevent collapsed controls from receiving focus;
- respect
prefers-reduced-motion; - keep visual state synchronized with
aria-expanded.
@media (prefers-reduced-motion: reduce) {
.accordion__panel {
transition: none;
}
}
An animation should clarify the relationship between the header and panel, not delay access to content.
12. Deep Linking and URL State
For documentation or settings pages, a section may need a shareable URL.
/help#returns
/settings?section=privacy
On navigation:
- read and validate the requested section ID;
- open that section;
- move or scroll to it only when appropriate;
- preserve expected Back and Forward behavior.
Do not store all accordion interactions in the URL automatically. Use URL state when a section is meaningful enough to bookmark or share.
13. Performance Considerations
Most accordions do not need special optimization. For complex panels:
- defer expensive work until the first expansion;
- cache fetched content when reopening should be fast;
- cancel obsolete requests if a panel unmounts;
- avoid loading data on every toggle;
- keep state close to the component that owns it;
- profile before adding memoization.
If panels contain forms, decide whether closing a panel should preserve unsaved work and communicate that behavior to users.
14. Common Mistakes
| Mistake | Better approach |
|---|---|
| Making only the chevron clickable | Make the complete header a button |
Using a clickable <div> | Use a native <button> |
| Updating only the visual icon | Keep visibility and aria-expanded synchronized |
| Using array indexes as identity | Use stable IDs from the data |
| Creating separate state for every panel | Store one open ID or a collection of open IDs |
| Hiding content visually but leaving it focusable | Use hidden or otherwise remove collapsed descendants from interaction |
| Using tab roles for an accordion | Follow disclosure/accordion semantics |
| Animating while ignoring reduced-motion preferences | Provide a reduced or instant transition |
| Collapsing a form and unexpectedly deleting its data | Choose mounting behavior intentionally |
| Nesting many interactive controls inside the header button | Keep the button's content simple and valid |
15. Testing Checklist
Behavior
- Clicking a closed header opens its panel.
- Clicking an open header closes it when collapsing is allowed.
- Single-open mode closes the previously open panel.
- Multiple-open mode preserves other open panels.
- Reordered data keeps state attached to the correct IDs.
Keyboard and Accessibility
- Every header is reachable with
Tab. EnterandSpacetoggle the focused header.- Focus remains on the activated header.
aria-expandedmatches visible state.aria-controlsandaria-labelledbypoint to existing unique IDs.- Collapsed content cannot receive focus.
- Screen readers announce meaningful header names and state.
Layout
- Long and translated headings wrap correctly.
- Icons remain understandable at zoomed text sizes.
- Animation works without clipping dynamic content.
- Reduced-motion settings are respected.
Official References
Summary
An accordion is state-driven disclosure: each accessible header controls one panel, while the group decides how many panels may remain open.
Think of every accordion item as a synchronized pair:
Header button
├── meaningful label
├── aria-expanded
└── aria-controls ───────────┐
↓
Content panel
├── matching ID
├── labelled by the header
└── visible only when expanded
The React state determines which pair is open. Stable identity, native controls, and matching accessibility attributes make that state understandable to every user.