React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

accordioncollapsibledisclosurecomponenttailwind

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 patternAccordionRoot, AccordionItem, AccordionTrigger, and AccordionContent compose freely. Each sub-component reads its state from context.
  • Single vs. multiple mode — the type prop controls whether only one panel or many can be open. The toggle logic branches on this value inside a single useCallback.
  • ARIA attributesaria-expanded, aria-controls, aria-labelledby, and role="region" match the WAI-ARIA Accordion pattern for screen reader support.
  • useId for unique IDs — React 19's useId generates stable, SSR-safe IDs for linking triggers to their content panels.
  • Animated heightmaxHeight transitions from 0px to the measured scrollHeight. Combined with opacity, the effect feels smooth without layout jank.
  • Disabled items — the disabled prop on AccordionItem grays 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 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.

  • Button — Trigger elements used in accordion headers
  • Tabs — Alternative pattern for switching between content sections
  • Modal — Overlay pattern for content that demands focused attention