Skip to content

React Tutorial Practical Projects

React FAQ Accordion Project with Accessible Toggles

Lesson 141 of 156 3 min read By Deepak Paswan Updated Oct 10, 2026

Runnable lesson · press Try it on the examples to edit and run them here.

An FAQ is the classic show/hide pattern. Doing it accessibly is the real learning: the markup matters as much as the state.

Requirements

  • A list of questions loaded from data, not repeated markup.
  • Clicking a question opens its answer and clicking again closes it.
  • A mode switch: many open at once or only one at a time.
  • Questions are real buttons, work with the keyboard and tell assistive technology whether they are expanded.
  • A “Show all” / “Hide all” control in multi-open mode.

The solution

App.jsx
JSX
const FAQS = [
  { id: "free", question: "Is it free?", answer: "Yes, the whole course is free." },
  { id: "need", question: "What do I need?", answer: "A browser. Node.js is only needed for the project lessons." },
  { id: "offline", question: "Can I use it offline?", answer: "Only the lessons you have downloaded or saved." },
];

function FaqItem({ item, open, onToggle }) {
  const panelId = "faq-panel-" + item.id;
  const buttonId = "faq-button-" + item.id;

  return (
    <div style={{ borderBottom: "1px solid #ddd" }}>
      <h3 style={{ margin: 0 }}>
        <button
          id={buttonId}
          aria-expanded={open}
          aria-controls={panelId}
          onClick={onToggle}
          style={{ width: "100%", textAlign: "left", padding: "12px 4px", font: "inherit", fontWeight: 600, background: "none", border: 0, cursor: "pointer", display: "flex", justifyContent: "space-between" }}
        >
          {item.question}
          <span aria-hidden="true">{open ? "−" : "+"}</span>
        </button>
      </h3>
      <div id={panelId} role="region" aria-labelledby={buttonId} hidden={!open} style={{ padding: "0 4px 12px" }}>
        {item.answer}
      </div>
    </div>
  );
}

function App() {
  const [singleMode, setSingleMode] = React.useState(false);
  const [openIds, setOpenIds] = React.useState([]);   // an array of open item ids

  function toggle(id) {
    setOpenIds((ids) => {
      if (ids.includes(id)) return ids.filter((x) => x !== id);   // close it
      return singleMode ? [id] : [...ids, id];                     // open it (and close others in single mode)
    });
  }

  function handleModeChange(e) {
    const single = e.target.checked;
    setSingleMode(single);
    if (single) setOpenIds((ids) => ids.slice(0, 1));              // keep at most one open
  }

  const allOpen = openIds.length === FAQS.length;

  return (
    <main style={{ fontFamily: "system-ui, sans-serif", maxWidth: 480 }}>
      <style>{`button:focus-visible { outline: 3px solid #ca4b00; outline-offset: 2px; }`}</style>
      <h1>Frequently asked questions</h1>

      <label>
        <input type="checkbox" checked={singleMode} onChange={handleModeChange} /> Only one open at a time
      </label>
      {!singleMode && (
        <p>
          <button onClick={() => setOpenIds(allOpen ? [] : FAQS.map((f) => f.id))}>
            {allOpen ? "Hide all" : "Show all"}
          </button>
        </p>
      )}

      {FAQS.map((item) => (
        <FaqItem key={item.id} item={item} open={openIds.includes(item.id)} onToggle={() => toggle(item.id)} />
      ))}
    </main>
  );
}

Walkthrough

  1. Data first. FAQS is an array of objects with stable ids. The screen maps over it with key={item.id} (Rendering Lists). Adding a question means adding an object.
  2. State is a list of open ids. One array handles both modes. In multi-open mode it can hold several ids, in single mode at most one. Which items are open is derived: openIds.includes(item.id).
  3. toggle uses the updater form and returns a new array (filter or spread) every time (Updating Arrays in State).
  4. Switching to single mode trims the list to one id, so the state never contradicts the rule.
  5. FaqItem is a small, stateless component. It receives open and onToggle, and does not know about modes.
  6. The accessibility markup follows the standard “disclosure” pattern: – the question is a real <button> inside a heading, so it is focusable, activates with Enter and Space and appears in the heading outline; – aria-expanded says whether it is open; – aria-controls points to the panel; – the panel is a role="region" labelled by its button, and uses the hidden attribute so closed answers are removed from the page for assistive technology as well as visually.

Decisions worth noticing

  • State in the parent, not in each item. The “Show all” button and the single-mode rule need to know about all items, so the state lives where all items are visible (Lifting State Up).
  • hidden instead of not rendering. It keeps the content in the page for search-in-page and lets the panel keep its state. Rendering {open && <div>...} also works, and is simpler if the answers are heavy.
  • Why not the native <details> element? It is a great choice for a plain FAQ with no mode switches: no JavaScript, accessible by default. Use it when it is enough; this project shows how to build the controlled version you need when something else must know which items are open.
  • FAQ and SEO. If an FAQ should be eligible for rich results in search, the structured data (JSON-LD) must match the visible questions. On a client-rendered page, check that crawlers can see it, or generate it on the server.

Make it yours

  1. Add arrow-key navigation between questions (Up, Down, Home, End) with refs.
  2. Animate the height of the panel with CSS (respect prefers-reduced-motion).
  3. Add a search box that filters questions (Search and Filtering).
  4. Open the item that matches the URL hash (#free) when the page loads.