Building an Accordion

Building an Accordion

Building an Accordion in React and Motion.

January 14, 2026

What is an Accordion?

An Accordion component is a very commonly interactive UI components seen on the web , You may have come across this component in an FAQ section , menu and sub-menus or feature list.

Accordion helps keep the ui clean by hiding information by default and revealing it when the user interacts with a section

Anatomy of a Accordion Component

accordion-anotomy

Header : The visible section of the accordion, it also act as an trigger.

Panel : The collapsed part , only visible when the accordion in opened.

Body : A wrapper around the both header and panel that holds them together.

Indicator : "+ / -"" or an caret 🔽 that indicates the accordion is open or close , This is optional.

A Basic Accordion

To start, I built a simple accordion using React state.

tsx
// BasicAccordion.tsx
const [openIndex, setOpenIndex] = useState<number | null>(null);

Only one panel can be open at a time. Clicking the same header again closes it.

tsx
// BasicAccordion.tsx
const [openIndex, setOpenIndex] = useState<number | null>(null);

Each accordion item is rendered from a data array.

The panel is conditionally shown based on the active index.

tsx
// BasicAccordion.tsx
{
openIndex === id && <div className="p-4">{item.panel}</div>;
}

To indicate state visually, the arrow icon rotates when the panel is open.

tsx
// BasicAccordion.tsx
"use client";
import clsx from "clsx";
import React, { useState } from "react";
const items = [
{
header: "header-1",
panel: "Answer panel",
},
{
header: "header-2",
panel: "Answer panel",
},
{
header: "header-3",
panel: "Answer panel",
},
];
const Accordion = () => {
const [openIndex, setOpenIndex] = useState<number | null>(null);
const toggleOpen = (index: number) => {
setOpenIndex(openIndex === index ? null : index);
};
return (
<div className="h-fit w-[60%]">
{items.map((item, id) => (
<div key={id} className="border border-neutral-200">
<button
className="flex w-full justify-between bg-neutral-200 p-2"
onClick={() => toggleOpen(id)}
>
<div>{item.header}</div>
<div
className={clsx(
"flex",
openIndex === id ? "rotate-180" : "rotate-0",
)}
>
<span className="rotate-0">🔽</span>
</div>
</button>
{openIndex === id && (
<div className="overflow-hidden p-4">{item.panel}</div>
)}
</div>
))}
</div>
);
};
export default Accordion;

Thinking in React Components

This still works but it has few issue that hold it back from being a re-usable component.

  • Separation of Concerns - Data, state, and presentation live in the same component.
  • Behavior option - Single and multiple open panels
  • Accessibilities - Tab , Aria lables etc

Separation of concerns

AccordionRoot owns the state in context for all the accordion, we also pass the functions to trigger the accordions. This is the outter wrapper for our component so we will pass behavior props through this, like multiple or single accordion.

AccordionItem is a wrapper for each item. it also holds its own logic as a scope for its children.

AccordionTrigger handles user interaction. Here we will call the function to trigger the opening of the accordion.

AccordionHeader is the title/ header for the accordion. Also has the indicator icon

AccordionPanel is the hidden information.

accordion-file-structure

Single vs Multiple Open Panels

By default i am keeping the accordions single open only, but when pass multiple prop to the root then it allows multiple panels

Inside the root , will switch the states.

  • A single value state will have type (string | null)
  • A list of values state will have type (string[])
  • Also the function toggleItem to change the state will live here.

This keeps the public API simple while allowing flexible behavior.

Accessibility

  • Interactive logic lives in AccordionTrigger
  • Visual content stays in AccordionHeader

React Patterns Used

  • Compound Components Breaks the accordion into small parts that work together and are easy to combine.
  • Context for Shared State Keeps the accordion state in one place so it can be shared without passing props around.
  • Behavior-Based APIs Shares actions like isOpen and toggleItem instead of exposing internal state directly.
  • Boolean Configuration (multiple) Makes the API easy to read. The default behavior stays simple, and extra features are added only when needed.

Building Compound Components

AccordionRoot

AccordionRoot acts as the brain of the accordion.

It owns all the shared state, decides whether the accordion works in single or multiple mode, exposes open and close behavior through context, and coordinates all accordion items so they work together as one group.

tsx
"use client";
import React, { useState } from "react";
import { AccordionContext } from "../context";
type AccordionRootProps = {
children: React.ReactNode;
multiple?: boolean;
};
const AccordionRoot = ({ children, multiple = false }: AccordionRootProps) => {
const [openItem, setOpenItem] = useState<string | null>(null);
const [openItems, setOpenItems] = useState<string[]>([]);
function toggleItem(value: string) {
if (multiple) {
setOpenItems((prev) =>
prev.includes(value)
? prev.filter((item) => item !== value)
: [...prev, value],
);
} else {
setOpenItem((prev) => (prev === value ? null : value));
}
}
const isOpen = (value: string) =>
multiple ? openItems.includes(value) : openItem === value;
return (
<AccordionContext.Provider value={{ isOpen, toggleItem }}>
{children}
</AccordionContext.Provider>
);
};
export default AccordionRoot;

All high-level decisions about how the accordion behaves live here.

AccordionItem

AccordionItem represents one section of the accordion. It defines a unique identity for the item, determines whether it is open or closed, provides scoped context to its child components, and keeps all item-level concerns isolated from the rest of the accordion.

This allows each item to remain independent while still participating in shared behavior.

tsx
//AccordionItem.tsx
"use client";
import { useAccordionContext } from "../context";
import AccordionItemContext from "../itemContext";
import clsx from "clsx";
const AccordionItem = ({ value, children, className }: any) => {
const { isOpen } = useAccordionContext();
const open = isOpen(value);
return (
<AccordionItemContext.Provider value={{ value, isOpen: open }}>
<div className={clsx("w-full", className)}>{children}</div>
</AccordionItemContext.Provider>
);
};
export default AccordionItem;

AccordionTrigger

AccordionTrigger handles all user interaction. It listens for click or keyboard events, signals the intent to open or close an accordion item, and does not own any state itself.

It acts as the communication layer between the UI and the accordion logic, and by separating interaction from state, the component remains predictable and easy to extend.

tsx
//AccordionTrigger.tsx
"use client";
import { useAccordionContext } from "../context";
import { useAccordionItemContext } from "../itemContext";
import clsx from "clsx";
const AccordionTrigger = ({ children, className }: any) => {
const { toggleItem } = useAccordionContext();
const { value, isOpen } = useAccordionItemContext();
return (
<button
aria-expanded={isOpen}
data-state={isOpen ? "open" : "closed"}
onClick={() => toggleItem(value)}
className={clsx(
"w-full",
"cursor-pointer select-none",
"focus:outline-none focus-visible:ring-2",
className,
)}
>
{children}
</button>
);
};
export default AccordionTrigger;

AccordionHeader

AccordionHeader is responsible only for presentation. It renders the visible header content, reflects the open or closed state visually, handles icons, transitions, and styling, and contains no business logic. This keeps visual decisions clearly isolated from behavior.

tsx
//AccordionHeader.tsx
"use client";
import clsx from "clsx";
import { useAccordionItemContext } from "../itemContext";
import { FaPlus } from "react-icons/fa6";
const AccordionHeader = ({ children }: any) => {
const { isOpen } = useAccordionItemContext();
return (
<div
className={
"group flex w-full items-center justify-between rounded-xl bg-neutral-50 p-3 dark:border dark:border-neutral-50/5 dark:bg-neutral-800"
}
>
<div className={"text-neutral-800 dark:text-neutral-100"}>{children}</div>
<div
className={clsx(
"group-hover:bg-brand group-hover:dark:bg-brand inline-flex aspect-square w-fit items-center rounded-lg px-1 py-1 text-sm whitespace-nowrap text-neutral-100 shadow-[0_1px_0_rgba(255,255,255,0.25)_inset,0_2px_4px_rgba(0,0,0,0.15)] transition-colors duration-300 ease-out",
isOpen ? "bg-brand" : "bg-neutral-400 dark:bg-neutral-700",
)}
>
<span
className={clsx(
"transition-transform duration-300 ease-in-out",
isOpen ? "rotate-45" : "rotate-0",
)}
>
{" "}
<FaPlus />{" "}
</span>
</div>
</div>
);
};
export default AccordionHeader;

AccordionPanel

AccordionPanel controls the collapsible content area. It renders content based on the open or closed state, manages layout and spacing, and serves as the expandable section of the accordion.

tsx
"use client";
import { useAccordionItemContext } from "../itemContext";
import { motion, AnimatePresence } from "motion/react";
const AccordionPanel = ({ children }: any) => {
const { isOpen } = useAccordionItemContext();
return (
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
className="overflow-hidden"
data-state="open"
>
<div className={"p-3 text-sm text-neutral-800 dark:text-neutral-400"}>
{children}
</div>
</motion.div>
)}
</AnimatePresence>
);
};
export default AccordionPanel;

Context Layer

The context layer provides shared communication between all accordion components. It exposes behavior instead of raw state, prevents prop drilling, and keeps every part of the accordion in sync. This shared context is what makes the compound component pattern possible.

tsx
//context.ts
"use client";
import { createContext, useContext } from "react";
import { AccordionContextTypes } from "./types";
// Create context
export const AccordionContext = createContext<AccordionContextTypes | null>(
null,
);
export function useAccordionContext() {
const context = useContext(AccordionContext);
if (!context) {
throw new Error("useAccordionContext must be used inside AccordionRoot");
}
return context;
}
ts
// itemContext.ts
"use client";
import { createContext, useContext } from "react";
import { AccordionItemContextTypes } from "./types";
const AccordionItemContext = createContext<AccordionItemContextTypes | null>(
null,
);
export function useAccordionItemContext() {
const context = useContext(AccordionItemContext);
if (!context) {
throw new Error("Accordion components must be used inside AccordionItem");
}
return context;
}
export default AccordionItemContext;

Import

Don't forget to import

ts
//index.ts
import AccordionRoot from "./root/AccordionRoot";
import AccordionHeader from "./header/AccordionHeader";
import AccordionTrigger from "./trigger/AccordionTrigger";
import AccordionItem from "./item/AccordionItem";
import AccordionPanel from "./panel/AccordionPanel";
export {
AccordionRoot,
AccordionHeader,
AccordionTrigger,
AccordionItem,
AccordionPanel,
};

Implementation

The final accordion API is declarative and easy to read. A single AccordionRoot wraps all items and controls the overall behavior. Adding the multiple prop allows more than one panel to stay open, while the default behavior keeps it limited to a single open panel.

Each AccordionItem represents one section, with AccordionTrigger handling interaction, AccordionHeader rendering the visible title, and AccordionPanel displaying the collapsible content. This structure keeps behavior, interaction, and presentation clearly separated and makes the component easy to understand and extend.

tsx
import {
AccordionRoot,
AccordionHeader,
AccordionTrigger,
AccordionItem,
AccordionPanel,
} from ".";
const accordionDemo = () => {
return (
<div className="flex w-[60%] flex-col items-center justify-center gap-1 rounded-2xl border border-white/50 bg-neutral-200 p-1.5 dark:border-white/5 dark:bg-neutral-800/20">
<AccordionRoot multiple>
<AccordionItem value="item-1">
<AccordionTrigger>
<AccordionHeader>What is a accordion</AccordionHeader>
</AccordionTrigger>
<AccordionPanel>
An accordion is a UI pattern used to show and hide content.
</AccordionPanel>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger>
<AccordionHeader>How does it work?</AccordionHeader>
</AccordionTrigger>
<AccordionPanel>
Users click or tap on a section header to expand it. Clicking again
collapses it, keeping the interface tidy.
</AccordionPanel>
</AccordionItem>
<AccordionItem value="item-3">
<AccordionTrigger>
<AccordionHeader>Where is it used?</AccordionHeader>
</AccordionTrigger>
<AccordionPanel>
Common in FAQs, settings panels, product descriptions, and anywhere
you want to group related content.
</AccordionPanel>
</AccordionItem>
</AccordionRoot>
</div>
);
};
export default accordionDemo;

Conclusions

This accordion evolved from a simple state-based component into a reusable UI primitive by applying clear separation of concerns and React patterns. Structuring the component thoughtfully made the API easier to read and extend.

Along the way, I learned how state modeling shapes behavior, how compound components improve composition, and how context helps keep complex UI predictable and scalable.