Classic Accordion

A classic accordion component that expands and collapses content sections to organize information efficiently.

Our pricing is based on usage and plan tier. You can upgrade, downgrade, or cancel at any time from your account settings.
Yes. We offer a 14-day free trial with full access to all features. No credit card is required to get started.
We provide email support on all plans. Priority and live chat support are available on higher-tier plans.
We use industry-standard encryption in transit and at rest. Access to customer data is restricted and monitored.

1. Copy the component file

Create a new file called classic-accordion.jsx in your reusable components folder (for example /src/components/) and paste the following code into it.

classic-accordion.jsx

import { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
import styles from "./classic-accordion.module.css";

const AccordionItem = (props) => {
  const {
    accordionGroupId,
    item,
    onToggle,
    open,
  } = props;

  const panelRef = useRef(null);

  const accordionHeaderId = useMemo((id) => (
    `${accordionGroupId}-header-${id}`
  ), [accordionGroupId]);

  const accordionPanelId = useMemo((id) => (
    `${accordionGroupId}-panel-${id}`
  ), [accordionGroupId]);

  useLayoutEffect(() => {
    const height = open ? panelRef.current.scrollHeight : 0;
    panelRef.current?.style.setProperty("--panel-height", `${height}px`);
  }, [open]);

  return (
    <div className={styles["accordion-item"]}>
      <div className={styles["header"]}>
        <button
          aria-controls={accordionPanelId}
          aria-expanded={open}
          id={accordionHeaderId}
          onClick={() => onToggle(item.id)}
        >
          <span
            className={styles["title"]}
            title={item.title}
          >
            {item.title}
          </span>
          <span className={styles["icon"]}>
          </span>
        </button>
      </div>
      <div
        aria-labelledby={accordionHeaderId}
        className={styles["panel"]}
        id={accordionPanelId}
        ref={panelRef}
        role="region"
      >
        <div className={styles["panel-content-wrapper"]}>
          {item.content}
        </div>
      </div>
    </div>
  );
};

const ClassicAccordion = (props) => {
  const {
    items = [],
    multiple = false,
    defaultOpen,
  } = props;

  const [openAccordionIds, setOpenAccordionIds] = useState(() => {
    if (multiple) {
      if(Array.isArray(defaultOpen)) {
        return [...defaultOpen];
      }
    } else if(
      typeof defaultOpen === "string" &&
      items.some(item => item.id === defaultOpen)
    ) {
      return [defaultOpen];
    }
    return [];
  });

  const accordionGroupId = useMemo(() => (
    `accordion-group-${Math.random().toString(36).substring(2)}`
  ), []);

  const handleAccordionToggle = useCallback((accordionId) => {
    setOpenAccordionIds((prev) => {
      const isOpen = prev.includes(accordionId);
      if (isOpen) { 
        return prev.filter(item => item !== accordionId);
      } else if (multiple) {
        return [...prev, accordionId];
      } else {
        return [accordionId];
      }
    });
  }, []);

  if(
    !Array.isArray(items) || 
    items.length === 0
  ) {
    return null;
  }

  return (
    <div className={styles["accordion-group"]}>
      {items.map((item, index) => (
        <AccordionItem
          key={item.id ?? index}
          accordionGroupId={accordionGroupId}
          item={item}
          open={openAccordionIds.includes(item.id)}
          onToggle={handleAccordionToggle}
        />
      ))}
    </div>
  );
};

export default memo(ClassicAccordion);

2. Copy the CSS module file

In the same folder, create a file called classic-accordion.module.css and paste the following CSS.

classic-accordion.module.css

.accordion-group {
  --accordion-border-color: #bbb;
  --accordion-header-text-color: #111;
  --accordion-padding: 16px;
  --accordion_open-background-color: #eee;
  --transition-duration: 250ms;

  @media (prefers-color-scheme: dark) {
    --accordion-header-text-color: #f5f5f5;
    --accordion-border-color: #222;
    --accordion_open-background-color: #111;
  }

  border: 1px solid var(--accordion-border-color);
  border-radius: 16px;
  overflow: hidden;

  >.accordion-item {
    border-bottom: 1px solid var(--accordion-border-color);

    &:last-child {
      border-bottom: none;
    }

    >.header {
      button {
        padding: var(--accordion-padding);
        width: 100%;
        display: flex;
        justify-content: space-between;
        gap: 8px;
        align-items: center;
        background: transparent;
        border: none;
        font: inherit;
        cursor: pointer;

        .title {
          display: inline-block;
          white-space: nowrap;
          overflow: hidden;
          text-overflow: ellipsis;
          color: var(--accordion-header-text-color);
        }

        .icon {
          position: relative;
          width: 12px;
          height: 12px;
          flex-shrink: 0;

          &:before,
          &:after {
            content: "";
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            width: 100%;
            height: 1px;
            background: var(--accordion-header-text-color);
          }

          &:before {
            transform: translate(-50%, -50%) rotate(90deg);
          }
        }
      }
    }

    >.panel {
      --panel-height: 0px;
      overflow: hidden;
      height: var(--panel-height) !important;
      border-top: 1px solid transparent;
      transition:
        height var(--transition-duration) ease-in-out,
        border 0ms linear var(--transition-duration);

      .panel-content-wrapper {
        padding: var(--accordion-padding);
      }
    }

    &:has([aria-expanded="true"]) {
      >.header {
        background: var(--accordion_open-background-color);

        >button {
          .icon {
            &:before {
              display: none;
            }
          }
        }
      }

      >.panel {
        border-top-color: var(--accordion-border-color);
        transition:
          height var(--transition-duration) ease-in-out,
          border 0ms linear 0ms;
      }
    }
  }
}

3. Use the component

Now you can import and use the component anywhere in your project.

classic-accordion-preview.jsx

import ClassicAccordion from "@/components/essentials/classic-accordion/classic-accordion";

const ClassicAccordionPreview = () => {
  return (
    <div style={{ 
      width: "100%", 
      maxWidth: "480px",
      padding: "16px",
    }}>
      <ClassicAccordion
        items={faq}
      />
    </div>
  )
};

const faq = [
  {
    id: "pricing",
    title: "How does pricing work?",
    content: `Our pricing is based on usage and plan tier. 
    You can upgrade, downgrade, or cancel at any time from your account settings.`,
  },
  {
    id: "trial",
    title: "Do you offer a free trial?",
    content: `Yes. We offer a 14-day free trial with full access to all features. 
    No credit card is required to get started.`,
  },
  {
    id: "support",
    title: "What kind of support do you provide?",
    content: `We provide email support on all plans. 
    Priority and live chat support are available on higher-tier plans.`,
  },
  {
    id: "security",
    title: "How is my data secured?",
    content: `We use industry-standard encryption in transit and at rest. 
    Access to customer data is restricted and monitored.`,
  },
];

export default ClassicAccordionPreview;

Open multiple accordions

Our pricing is based on usage and plan tier. You can upgrade, downgrade, or cancel at any time from your account settings.
Yes. We offer a 14-day free trial with full access to all features. No credit card is required to get started.
We provide email support on all plans. Priority and live chat support are available on higher-tier plans.
We use industry-standard encryption in transit and at rest. Access to customer data is restricted and monitored.

classic-accordion

import ClassicAccordion from "@/components/essentials/classic-accordion/classic-accordion";

const ClassicAccordionMultiplePreview = () => {
  return (
    <div style={{ 
      width: "100%", 
      maxWidth: "480px",
      padding: "16px",
    }}>
      <ClassicAccordion
        items={faq}
        multiple
      />
    </div>
  )
};

const faq = [
  {
    id: "pricing",
    title: "How does pricing work?",
    content: `Our pricing is based on usage and plan tier. 
    You can upgrade, downgrade, or cancel at any time from your account settings.`,
  },
  {
    id: "trial",
    title: "Do you offer a free trial?",
    content: `Yes. We offer a 14-day free trial with full access to all features. 
    No credit card is required to get started.`,
  },
  {
    id: "support",
    title: "What kind of support do you provide?",
    content: `We provide email support on all plans. 
    Priority and live chat support are available on higher-tier plans.`,
  },
  {
    id: "security",
    title: "How is my data secured?",
    content: `We use industry-standard encryption in transit and at rest. 
    Access to customer data is restricted and monitored.`,
  },
];

export default ClassicAccordionMultiplePreview;

Props

Configure the component with the following props:

PropTypeRequiredDefaultDescription
items{ id: string; title: React.ReactNode; content: React.ReactNode }[]Yes[]Array of accordion items to render.
multiplebooleanNofalseAllows multiple panels to be open at the same time.
defaultOpenstring | string[]NoID (or array of IDs when multiple is true) of item(s) open by default.