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
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
- Data first.
FAQSis an array of objects with stableids. The screen maps over it withkey={item.id}(Rendering Lists). Adding a question means adding an object. - 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). toggleuses the updater form and returns a new array (filteror spread) every time (Updating Arrays in State).- Switching to single mode trims the list to one id, so the state never contradicts the rule.
FaqItemis a small, stateless component. It receivesopenandonToggle, and does not know about modes.- 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-expandedsays whether it is open; –aria-controlspoints to the panel; – the panel is arole="region"labelled by its button, and uses thehiddenattribute 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).
hiddeninstead 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
- Add arrow-key navigation between questions (Up, Down, Home, End) with refs.
- Animate the height of the panel with CSS (respect
prefers-reduced-motion). - Add a search box that filters questions (Search and Filtering).
- Open the item that matches the URL hash (
#free) when the page loads.