Accordion
A collapsible content panel component for showing and hiding sections of related content, reducing visual clutter while keeping information accessible.
Search across all documentation pages
A collapsible content panel component for showing and hiding sections of related content, reducing visual clutter while keeping information accessible.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
"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.
"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.
"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.
"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.
"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.
"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.
"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.
"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:
AccordionRoot, AccordionItem, AccordionTrigger, and AccordionContent compose freely. Each sub-component reads its state from context.type prop controls whether only one panel or many can be open. The toggle logic branches on this value inside a single useCallback.aria-expanded, aria-controls, aria-labelledby, and role="region" match the WAI-ARIA Accordion pattern for screen reader support.useId generates stable, SSR-safe IDs for linking triggers to their content panels.maxHeight transitions from 0px to the measured scrollHeight. Combined with opacity, the effect feels smooth without layout jank.disabled prop on AccordionItem grays out the trigger and prevents toggling, useful for "coming soon" or locked sections.Content height changes after mount - If accordion content includes images or async data, the measured scrollHeight may be wrong. Re-measure height when content changes or use a ResizeObserver.
overflow-hidden clipping child elements - Tooltips, dropdowns, or popovers inside an accordion panel get clipped by overflow-hidden. Use a portal for those nested overlays.
Missing aria-expanded on the trigger - Screen readers rely on aria-expanded to 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> with onClick is 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 scrollHeight is measured before content renders, the animation starts from 0px to 0px. Measure in a useEffect that runs after the content mounts.
State reset on re-render - If the accordion items array is recreated on each render (inline array literal), item value keys may not match and the open state resets. Stabilize the items array with useMemo or define it outside the component.
Reviewed by Chris St. John·Last updated Jul 10, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥