Accordion
A collapsible content panel component for showing and hiding sections of related content, reducing visual clutter while keeping information accessible.
Use Cases
- FAQ pages with question/answer pairs
- Settings panels with grouped options
- Navigation sidebars with expandable sections
- Product detail pages with specs, reviews, and shipping info
- Form sections that expand progressively
- Documentation pages with collapsible code examples
- Filter panels in search interfaces
Simplest Implementation
"use client";
import { useState } from "react";
interface AccordionProps {
title: string;
children: React.ReactNode;
}
export function Accordion({ title, children }: AccordionProps) {
const [open, setOpen] = useState(false);
return (
<div className="border-b border-gray-200">
<button
onClick={() => setOpen(!open)}
className="flex w-full items-center justify-between py-4 text-left text-sm font-medium text-gray-900 hover:text-blue-600"
>
{title}
<svg
className={`h-5 w-5 shrink-0 text-gray-500 transition-transform ${open ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && <div className="pb-4 text-sm text-gray-600">{children}</div>}
</div>
);
}A minimal toggle using useState. The chevron rotates with a CSS transition. Content is conditionally rendered, so it doesn't affect the DOM when collapsed.
Variations
Single Open (Exclusive)
"use client";
import { useState } from "react";
interface AccordionItem {
id: string;
title: string;
content: React.ReactNode;
}
interface AccordionGroupProps {
items: AccordionItem[];
}
export function AccordionGroup({ items }: AccordionGroupProps) {
const [openId, setOpenId] = useState<string | null>(null);
return (
<div className="divide-y divide-gray-200 rounded-xl border border-gray-200">
{items.map((item) => {
const isOpen = openId === item.id;
return (
<div key={item.id}>
<button
onClick={() => setOpenId(isOpen ? null : item.id)}
aria-expanded={isOpen}
className="flex w-full items-center justify-between px-6 py-4 text-left text-sm font-medium text-gray-900 hover:bg-gray-50"
>
{item.title}
<svg
className={`h-5 w-5 shrink-0 text-gray-500 transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{isOpen && <div className="px-6 pb-4 text-sm text-gray-600">{item.content}</div>}
</div>
);
})}
</div>
);
}Stores a single openId so only one panel is open at a time. Clicking the same item again closes it by setting the state to null.
Multiple Open
"use client";
import { useState } from "react";
interface AccordionItem {
id: string;
title: string;
content: React.ReactNode;
}
interface AccordionGroupProps {
items: AccordionItem[];
defaultOpen?: string[];
}
export function AccordionGroup({ items, defaultOpen = [] }: AccordionGroupProps) {
const [openIds, setOpenIds] = useState<Set<string>>(new Set(defaultOpen));
function toggle(id: string) {
setOpenIds((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}
return (
<div className="divide-y divide-gray-200 rounded-xl border border-gray-200">
{items.map((item) => {
const isOpen = openIds.has(item.id);
return (
<div key={item.id}>
<button
onClick={() => toggle(item.id)}
aria-expanded={isOpen}
className="flex w-full items-center justify-between px-6 py-4 text-left text-sm font-medium text-gray-900 hover:bg-gray-50"
>
{item.title}
<svg
className={`h-5 w-5 shrink-0 text-gray-500 transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{isOpen && <div className="px-6 pb-4 text-sm text-gray-600">{item.content}</div>}
</div>
);
})}
</div>
);
}Uses a Set instead of a single ID so multiple panels can be open simultaneously. The defaultOpen prop accepts an array of IDs to pre-expand on mount.
With Plus/Minus Icons
"use client";
import { useState } from "react";
interface AccordionProps {
title: string;
children: React.ReactNode;
}
export function Accordion({ title, children }: AccordionProps) {
const [open, setOpen] = useState(false);
return (
<div className="border-b border-gray-200">
<button
onClick={() => setOpen(!open)}
aria-expanded={open}
className="flex w-full items-center justify-between py-4 text-left text-sm font-medium text-gray-900 hover:text-blue-600"
>
{title}
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-gray-100 text-gray-600">
{open ? (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
</svg>
) : (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
)}
</span>
</button>
{open && <div className="pb-4 text-sm text-gray-600">{children}</div>}
</div>
);
}Swaps between plus and minus SVG icons instead of rotating a chevron. The icon sits in a circular background for added visual weight.
Animated Content
"use client";
import { useState, useRef, useEffect } from "react";
interface AccordionProps {
title: string;
children: React.ReactNode;
}
export function Accordion({ title, children }: AccordionProps) {
const [open, setOpen] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState(0);
useEffect(() => {
if (contentRef.current) {
setHeight(contentRef.current.scrollHeight);
}
}, [children]);
return (
<div className="border-b border-gray-200">
<button
onClick={() => setOpen(!open)}
aria-expanded={open}
className="flex w-full items-center justify-between py-4 text-left text-sm font-medium text-gray-900 hover:text-blue-600"
>
{title}
<svg
className={`h-5 w-5 shrink-0 text-gray-500 transition-transform duration-300 ${open ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
<div
className="overflow-hidden transition-all duration-300 ease-in-out"
style={{ maxHeight: open ? `${height}px` : "0px" }}
>
<div ref={contentRef} className="pb-4 text-sm text-gray-600">
{children}
</div>
</div>
</div>
);
}Animates the panel open/close by transitioning maxHeight between 0px and the measured scrollHeight. The content is always in the DOM (not conditionally rendered) so the height can be measured.
Controlled Accordion
"use client";
interface AccordionProps {
title: string;
open: boolean;
onToggle: () => void;
children: React.ReactNode;
}
export function Accordion({ title, open, onToggle, children }: AccordionProps) {
return (
<div className="border-b border-gray-200">
<button
onClick={onToggle}
aria-expanded={open}
className="flex w-full items-center justify-between py-4 text-left text-sm font-medium text-gray-900 hover:text-blue-600"
>
{title}
<svg
className={`h-5 w-5 shrink-0 text-gray-500 transition-transform duration-200 ${open ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && <div className="pb-4 text-sm text-gray-600">{children}</div>}
</div>
);
}
// Usage:
// const [openIndex, setOpenIndex] = useState<number | null>(null);
// {items.map((item, i) => (
// <Accordion
// key={i}
// title={item.title}
// open={openIndex === i}
// onToggle={() => setOpenIndex(openIndex === i ? null : i)}
// >
// {item.content}
// </Accordion>
// ))}A fully controlled variant where the parent owns the open/close state. This is useful when external actions (like a "collapse all" button or URL hash changes) need to control which panels are open.
FAQ Style
"use client";
import { useState } from "react";
interface FAQItem {
question: string;
answer: string;
}
interface FAQAccordionProps {
items: FAQItem[];
}
export function FAQAccordion({ items }: FAQAccordionProps) {
const [openIndex, setOpenIndex] = useState<number | null>(null);
return (
<div className="mx-auto max-w-2xl">
<dl className="divide-y divide-gray-200">
{items.map((item, index) => {
const isOpen = openIndex === index;
return (
<div key={index} className="py-4">
<dt>
<button
onClick={() => setOpenIndex(isOpen ? null : index)}
aria-expanded={isOpen}
className="flex w-full items-center justify-between text-left text-base font-medium text-gray-900 hover:text-blue-600"
>
{item.question}
<svg
className={`ml-4 h-5 w-5 shrink-0 text-gray-500 transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
</dt>
{isOpen && (
<dd className="mt-3 text-sm leading-relaxed text-gray-600">
{item.answer}
</dd>
)}
</div>
);
})}
</dl>
</div>
);
}Uses semantic <dl>, <dt>, and <dd> elements which are appropriate for question/answer pairs. The leading-relaxed on answers improves readability for longer text blocks.
Complex Implementation
"use client";
import {
createContext,
useContext,
useState,
useRef,
useEffect,
useCallback,
useId,
} from "react";
// --- Context ---
interface AccordionContextValue {
openIds: Set<string>;
toggle: (id: string) => void;
}
const AccordionContext = createContext<AccordionContextValue | null>(null);
function useAccordionContext() {
const ctx = useContext(AccordionContext);
if (!ctx) throw new Error("Accordion.Item must be used inside Accordion.Root");
return ctx;
}
// --- Root ---
interface AccordionRootProps {
type?: "single" | "multiple";
defaultOpen?: string[];
children: React.ReactNode;
className?: string;
}
export function AccordionRoot({
type = "single",
defaultOpen = [],
children,
className,
}: AccordionRootProps) {
const [openIds, setOpenIds] = useState<Set<string>>(new Set(defaultOpen));
const toggle = useCallback(
(id: string) => {
setOpenIds((prev) => {
if (type === "single") {
return prev.has(id) ? new Set() : new Set([id]);
}
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
},
[type]
);
return (
<AccordionContext.Provider value={{ openIds, toggle }}>
<div className={`divide-y divide-gray-200 ${className ?? ""}`} role="presentation">
{children}
</div>
</AccordionContext.Provider>
);
}
// --- Item ---
interface AccordionItemProps {
value: string;
children: React.ReactNode;
disabled?: boolean;
}
interface ItemContextValue {
value: string;
isOpen: boolean;
isDisabled: boolean;
triggerId: string;
contentId: string;
}
const ItemContext = createContext<ItemContextValue | null>(null);
function useItemContext() {
const ctx = useContext(ItemContext);
if (!ctx) throw new Error("Must be used inside Accordion.Item");
return ctx;
}
export function AccordionItem({ value, children, disabled = false }: AccordionItemProps) {
const { openIds } = useAccordionContext();
const uid = useId();
const triggerId = `accordion-trigger-${uid}`;
const contentId = `accordion-content-${uid}`;
return (
<ItemContext.Provider
value={{
value,
isOpen: openIds.has(value),
isDisabled: disabled,
triggerId,
contentId,
}}
>
<div data-state={openIds.has(value) ? "open" : "closed"}>{children}</div>
</ItemContext.Provider>
);
}
// --- Trigger ---
export function AccordionTrigger({ children }: { children: React.ReactNode }) {
const { value, isOpen, isDisabled, triggerId, contentId } = useItemContext();
const { toggle } = useAccordionContext();
return (
<button
id={triggerId}
onClick={() => !isDisabled && toggle(value)}
aria-expanded={isOpen}
aria-controls={contentId}
aria-disabled={isDisabled}
className={`flex w-full items-center justify-between px-6 py-4 text-left text-sm font-medium transition-colors ${
isDisabled
? "cursor-not-allowed text-gray-400"
: "text-gray-900 hover:bg-gray-50 hover:text-blue-600"
}`}
>
{children}
<svg
className={`h-5 w-5 shrink-0 transition-transform duration-300 ${
isOpen ? "rotate-180" : ""
} ${isDisabled ? "text-gray-300" : "text-gray-500"}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
);
}
// --- Content ---
export function AccordionContent({ children }: { children: React.ReactNode }) {
const { isOpen, triggerId, contentId } = useItemContext();
const contentRef = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState(0);
useEffect(() => {
if (contentRef.current) {
setHeight(contentRef.current.scrollHeight);
}
}, [children, isOpen]);
return (
<div
id={contentId}
role="region"
aria-labelledby={triggerId}
className="overflow-hidden transition-all duration-300 ease-in-out"
style={{ maxHeight: isOpen ? `${height}px` : "0px", opacity: isOpen ? 1 : 0 }}
>
<div ref={contentRef} className="px-6 pb-4 text-sm leading-relaxed text-gray-600">
{children}
</div>
</div>
);
}
// --- Usage Example ---
// <AccordionRoot type="single" defaultOpen={["item-1"]}>
// <AccordionItem value="item-1">
// <AccordionTrigger>What is your refund policy?</AccordionTrigger>
// <AccordionContent>We offer a 30-day money-back guarantee.</AccordionContent>
// </AccordionItem>
// <AccordionItem value="item-2">
// <AccordionTrigger>How do I cancel?</AccordionTrigger>
// <AccordionContent>Go to Settings and click Cancel Subscription.</AccordionContent>
// </AccordionItem>
// <AccordionItem value="item-3" disabled>
// <AccordionTrigger>Enterprise pricing (coming soon)</AccordionTrigger>
// <AccordionContent>Contact sales for enterprise pricing.</AccordionContent>
// </AccordionItem>
// </AccordionRoot>Key aspects:
- Compound component pattern —
AccordionRoot,AccordionItem,AccordionTrigger, andAccordionContentcompose freely. Each sub-component reads its state from context. - Single vs. multiple mode — the
typeprop controls whether only one panel or many can be open. The toggle logic branches on this value inside a singleuseCallback. - ARIA attributes —
aria-expanded,aria-controls,aria-labelledby, androle="region"match the WAI-ARIA Accordion pattern for screen reader support. - useId for unique IDs — React 19's
useIdgenerates stable, SSR-safe IDs for linking triggers to their content panels. - Animated height —
maxHeighttransitions from0pxto the measuredscrollHeight. Combined withopacity, the effect feels smooth without layout jank. - Disabled items — the
disabledprop onAccordionItemgrays out the trigger and prevents toggling, useful for "coming soon" or locked sections.
Gotchas
-
Content height changes after mount — If accordion content includes images or async data, the measured
scrollHeightmay be wrong. Re-measure height when content changes or use aResizeObserver. -
overflow-hiddenclipping child elements — Tooltips, dropdowns, or popovers inside an accordion panel get clipped byoverflow-hidden. Use a portal for those nested overlays. -
Missing
aria-expandedon the trigger — Screen readers rely onaria-expandedto announce whether a section is open or closed. Always set it on the button element. -
Using
<div>as the trigger instead of<button>— A<div>withonClickis not keyboard accessible by default. Always use a<button>for accordion triggers to get built-in focus and Enter/Space handling. -
Animation flicker on first open — If
scrollHeightis measured before content renders, the animation starts from0pxto0px. Measure in auseEffectthat runs after the content mounts. -
State reset on re-render — If the accordion items array is recreated on each render (inline array literal), item
valuekeys may not match and the open state resets. Stabilize the items array withuseMemoor define it outside the component.