Accordion
by Pratik Rai
Read the full write-upBuild an accordion: a list of headers where clicking one reveals its panel, and clicking it again hides it.
Sections open independently — opening one does not close the others.
Requirements
All sections start closed.
Clicking a header opens its panel; clicking the same header again closes it.
Several sections can be open at the same time.
The chevron for an open section is rotated (the
.chevron-openclass already does this).Each header carries
aria-expandedreflecting its state, andaria-controlspointing at the id of the panel it opens.
Notes
The markup and styling are already there — only the open/closed behaviour is missing.
A closed panel should not be in the DOM at all, rather than hidden with CSS.
Hints
Accordion (reference solution)
One way to do it, not the only way. Yours passes if it renders.
Solution files
1import { useState } from 'react';
2
3const SECTIONS = [
4 {
5 id: 'what',
6 title: 'What is an accordion?',
7 body: 'A vertical list of headers, each revealing a panel of content when opened.',
8 },
9 {
10 id: 'when',
11 title: 'When should you use one?',
12 body: 'When the content is long, scannable by heading, and rarely needed all at once.',
13 },
14 {
15 id: 'a11y',
16 title: 'What does it need to be accessible?',
17 body: 'A real button per header, aria-expanded that tracks the state, and the panel tied to it by id.',
18 },
19];
20
21function Chevron({ open }) {
22 return (
23 <svg
24 className={'chevron' + (open ? ' chevron-open' : '')}
25 width="16" height="16" viewBox="0 0 24 24" fill="none"
26 stroke="currentColor" strokeWidth="2" aria-hidden="true"
27 >
28 <polyline points="6 9 12 15 18 9" />
29 </svg>
30 );
31}
32
33export default function App() {
34 // A Set of open ids. An array would work too, but a Set says "membership"
35 // out loud and makes the has/add/delete read like the question being asked.
36 const [openIds, setOpenIds] = useState(() => new Set());
37
38 const toggle = (id) =>
39 setOpenIds((current) => {
40 // Copy before mutating: React compares by reference, and editing the
41 // existing Set in place would re-render nothing.
42 const next = new Set(current);
43 if (next.has(id)) next.delete(id);
44 else next.add(id);
45 return next;
46 });
47
48 return (
49 <div>
50 <h1>Accordion</h1>
51 <div className="accordion">
52 {SECTIONS.map((section) => {
53 const open = openIds.has(section.id);
54 const panelId = section.id + '-panel';
55 return (
56 <div className="item" key={section.id}>
57 <button
58 className="header"
59 onClick={() => toggle(section.id)}
60 aria-expanded={open}
61 aria-controls={panelId}
62 >
63 {section.title}
64 <Chevron open={open} />
65 </button>
66
67 {open && (
68 <div className="panel" id={panelId}>
69 {section.body}
70 </div>
71 )}
72 </div>
73 );
74 })}
75 </div>
76 </div>
77 );
78}How it works
The whole problem is choosing the shape of the state. "Which section is open" is one value and gives you tabs; "which sections are open" is a collection and gives you an accordion. Interviewers often follow up by asking for the single-open variant, and the answer is that the state becomes one id instead of a set — the rest of the component barely moves.
The Set copy is the part people trip on. Set.prototype.add mutates and returns the same object, so returning it from a state updater hands React a reference it has already seen and the render is skipped. Copying first is what makes the update visible.
Rendering the panel conditionally rather than hiding it with CSS keeps closed content out of the accessibility tree and out of tab order, which is what aria-expanded is promising.