Building an Accordion

Building an Accordion in React and Motion.

build-an-accordion-cover
Published December 20, 20256 min readUpdated August 24, 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

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.

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

// 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.

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

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

// 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

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.

This keeps the public API simple while allowing flexible behavior.

Accessibility

React Patterns Used

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.

'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.

//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.

//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.

//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.

'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.

//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
}
// 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

//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.

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.