Problem Understanding
Before writing any code, there's a critical clarifying question to ask the interviewer: single-open or multi-open? Single-open means only one panel can be expanded at a time — opening a new panel automatically closes the previous one. Multi-open means any number of panels can be open simultaneously. The state design is completely different for each.
- Single-open: One
openId value in state. Simple, most common in interview questions.
- Multi-open: A
Set of open IDs in state. Slightly more complex.
Ask this question. Don't assume. Asking clarifying questions is itself something interviewers are evaluating.
State Design — One Value Is Enough
For single-open mode, this is all you need:
const [openId, setOpenId] = useState(null); // null = all closed
Whether an item is open is derived, not stored:
const isOpen = openId === item.id; // derived per item
⚠️ Common Mistake: Storing an isOpen boolean inside each item object, then updating the whole items array on every click. This is a state explosion — you're managing N state variables when 1 is enough. If you have 10 accordion items, you only ever need to know which ONE is open, not the open/closed status of all 10 individually.
The Toggle Logic — One Line
function toggle(id) {
setOpenId(prev => prev === id ? null : id);
}
Let's trace through this:
- All closed (
openId = null). User clicks item "q2". null === "q2" is false → openId becomes "q2". Panel "q2" opens.
- Item "q2" is open. User clicks "q2" again.
"q2" === "q2" is true → openId becomes null. Panel closes.
- Item "q2" is open. User clicks "q3".
"q2" === "q3" is false → openId becomes "q3". "q2" closes, "q3" opens.
One ternary handles all three cases. This is the most elegant formulation of accordion toggle logic, and it's worth memorizing.
💡 Interview Tip: Use the functional form of setState (prev => ...) here. If you call toggle rapidly, the functional form always uses the latest state value, not a stale closure capture. It's a good habit to use it whenever the new state depends on the old state.
Animation — The max-height Technique
This is where many candidates get stuck. CSS cannot animate height: auto. It's a known limitation — the browser doesn't know what "auto" is until after layout, so it can't interpolate between two values. The workaround is max-height:
/* Closed state */
.accordion-panel {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
}
/* Open state — set max-height to more than the content will ever be */
.accordion-panel-open {
max-height: 600px;
}
Or as inline styles:
<div
style={{ maxHeight: isOpen ? '600px' : '0', overflow: 'hidden', transition: 'max-height 0.3s ease' }}
>
{item.content}
</div>
The transition animates from 0 → 600px on open and 600px → 0 on close. The actual content height doesn't matter as long as 600px is larger than it. The slight imperfection of this approach (the timing is based on 600px travel even if content is only 100px tall) is acceptable and widely used in production.
💡 Interview Tip: If asked "why not transition height directly?", explain: "CSS can't animate height:auto because the browser doesn't know the computed height until after layout. max-height is the standard workaround — you pick a value large enough to contain the content."
Full Implementation
const ITEMS = [
{ id: 'q1', title: 'What is React?', content: 'React is a JavaScript library for building user interfaces...' },
{ id: 'q2', title: 'What is a hook?', content: 'Hooks are functions that let you use React features in function components...' },
{ id: 'q3', title: 'What is the virtual DOM?', content: 'The virtual DOM is a lightweight in-memory representation...' },
];
export default function Accordion() {
const [openId, setOpenId] = useState(null);
function toggle(id) {
setOpenId(prev => prev === id ? null : id);
}
return (
<div className="accordion">
{ITEMS.map(item => {
const isOpen = openId === item.id; // derived
return (
<div key={item.id} className="accordion-item">
{/* Header button */}
<button
className="accordion-header"
onClick={() => toggle(item.id)}
aria-expanded={isOpen}
aria-controls={`panel-${item.id}`}
id={`btn-${item.id}`}
>
<span>{item.title}</span>
<span
className="accordion-chevron"
style={{ transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)' }}
>
▾
</span>
</button>
{/* Content panel */}
<div
id={`panel-${item.id}`}
role="region"
aria-labelledby={`btn-${item.id}`}
aria-hidden={!isOpen}
style={{
maxHeight: isOpen ? '600px' : '0',
overflow: 'hidden',
transition: 'max-height 0.3s ease',
}}
>
<div className="accordion-content">{item.content}</div>
</div>
</div>
);
})}
</div>
);
}
ARIA Breakdown — Why Each Attribute Matters
- aria-expanded={isOpen}: Tells screen readers whether this button controls an expanded or collapsed panel. Reads as "collapsed" or "expanded" before the button label.
- aria-controls={`panel-${item.id}`}: Links the button to the panel it controls. Screen readers can jump to the associated content.
- role="region": Makes the panel a landmark that screen readers can navigate to. Combined with aria-labelledby, it becomes a named region.
- aria-labelledby={`btn-${item.id}`}: Associates the panel with its header button for labeling purposes.
- aria-hidden={!isOpen}: Hides the collapsed panel content from the accessibility tree entirely. Screen readers won't read hidden content.
💡 Interview Tip: Most candidates only add aria-expanded. Adding aria-controls, role=region, and aria-labelledby shows you've actually read the ARIA specification for the disclosure widget pattern. Say: "I'm following the ARIA disclosure pattern — aria-controls links the button to its panel, and role=region with aria-labelledby makes the panel a named accessible landmark."
Chevron Animation
<span style={{
transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)',
transition: 'transform 0.3s ease',
display: 'inline-block', // required for transform to work on inline elements!
}}>
▾
</span>
⚠️ Common Mistake: Forgetting display: inline-block on the chevron span. CSS transforms don't work on inline elements by default. Without it, the chevron won't rotate even though the styles look correct. This is a subtle CSS gotcha that's easy to miss.
Common Pitfalls Summary
- Per-item isOpen state: Storing an isOpen boolean inside each item object requires updating the entire array on every click. One openId is simpler, faster, and easier to reason about.
- Using array index as the ID: If items can be added, removed, or reordered, the index-based identification breaks. Always use a stable, unique id from the data.
- Transitioning height:auto: CSS cannot animate auto values. Use max-height instead.
- Forgetting display:inline-block on the chevron: Transforms don't apply to inline elements.
- Using display:none for hiding: display:none removes the element from the DOM entirely — no animation possible. Use max-height:0 with overflow:hidden instead.
How to Communicate During the Interview
- "First I want to clarify — should only one panel be open at a time, or can multiple be open? That determines whether I need one openId or a Set of open IDs."
- "I'm using a single openId state — null means all closed. The toggle is one line: if the clicked ID is already open, set openId to null, otherwise set it to the clicked ID."
- "For the animation, I can't transition height:auto, so I'm using the max-height technique — transition from 0 to a value large enough to contain any content."
- "I'm following the ARIA disclosure pattern: aria-expanded on the button, aria-controls linking it to the panel, role=region and aria-labelledby on the panel itself."