Accordion FAQ
Machine-coding brief for an accessible accordion FAQ — single/multi expand, keyboard, ARIA, and clean state.
- machine-coding
- interview
- react
- a11y
Problem statement
Build an accordion FAQ: a list of questions; clicking a header expands/collapses the answer panel. Interviewers score ARIA correctness, keyboard support, and whether you model open state cleanly — not fancy CSS.
Requirements
Must have
- Render N items from data (
id,question,answer) - Click header toggles that panel
- Only one open at a time or multi-open (clarify; support both via prop)
- Keyboard: Enter/Space on header toggles; optional ↑/↓ between headers
- Correct ARIA:
button+aria-expanded+aria-controls→ panelid+role="region"/aria-labelledby - Visible focus styles
Should have
- Controlled + uncontrolled modes (
value/defaultValue/onChange) - Animate height with
prefers-reduced-motionrespect - Disable chevron animation when reduced motion is on
Nice to have
- Deep-link open item via
?faq=idor hash - Search/filter questions
- Nested accordion (usually refuse unless asked)
Planning (5 minutes out loud)
- Single vs multi —
string | nullvsSet<string> - Ids — stable ids for ARIA wiring
- Header is a button — never a clickable
div - MVP — toggle + one-open; then multi + keyboard nav
- A11y first — expanded state announced by AT via
aria-expanded
Architecture
Accordion
├── AccordionItem
│ ├── AccordionHeader // button
│ └── AccordionPanel // region
└── types / useAccordionState
Data model
type AccordionItemData = {
id: string;
question: string;
answer: React.ReactNode; // string OK for FAQ
};
type Mode = "single" | "multiple";
Component API
type AccordionProps = {
items: AccordionItemData[];
mode?: Mode; // default "single"
/** controlled open ids */
value?: string[];
defaultValue?: string[];
onValueChange?: (openIds: string[]) => void;
className?: string;
};
Keep the API list-of-ids even in single mode so switching mode does not rewrite consumers.
Implementation sketch
State helper
function useAccordionState({
mode = "single",
value,
defaultValue = [],
onValueChange,
}: Pick<AccordionProps, "mode" | "value" | "defaultValue" | "onValueChange">) {
const [uncontrolled, setUncontrolled] = useState<string[]>(defaultValue);
const openIds = value ?? uncontrolled;
function setOpen(next: string[]) {
if (value === undefined) setUncontrolled(next);
onValueChange?.(next);
}
function toggle(id: string) {
const isOpen = openIds.includes(id);
if (mode === "single") {
setOpen(isOpen ? [] : [id]);
return;
}
setOpen(isOpen ? openIds.filter((x) => x !== id) : [...openIds, id]);
}
return { openIds, toggle, isOpen: (id: string) => openIds.includes(id) };
}
Item markup
function AccordionItem({
item,
open,
onToggle,
}: {
item: AccordionItemData;
open: boolean;
onToggle: () => void;
}) {
const panelId = `${item.id}-panel`;
const headerId = `${item.id}-header`;
return (
<div className="acc-item" data-state={open ? "open" : "closed"}>
<h3>
<button
type="button"
id={headerId}
aria-expanded={open}
aria-controls={panelId}
onClick={onToggle}
>
{item.question}
<span aria-hidden="true">{open ? "−" : "+"}</span>
</button>
</h3>
<div
id={panelId}
role="region"
aria-labelledby={headerId}
hidden={!open}
// or use CSS grid 0fr/1fr for height animation without hidden
>
<div className="acc-panel-inner">{item.answer}</div>
</div>
</div>
);
}
Height animation (optional)
Prefer CSS:
.acc-panel {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 200ms ease;
}
.acc-item[data-state="open"] .acc-panel {
grid-template-rows: 1fr;
}
.acc-panel-inner {
overflow: hidden;
}
@media (prefers-reduced-motion: reduce) {
.acc-panel {
transition: none;
}
}
If you use hidden, you cannot animate. Pick one: a11y-simple (hidden) for MVP, or keep in DOM with inert / aria-hidden + max-height/grid for motion.
Accessibility essentials
| Concern | Approach |
|---|---|
| Semantics | Question in heading; control is <button> |
| State | aria-expanded mirrors open |
| Association | aria-controls ↔ panel id |
| Keyboard | Space/Enter activate button natively |
| Optional roving | Arrow keys move focus between headers |
| Reduced motion | Disable height/chevron transitions |
Do not put role="button" on a real button. Do not toggle on the whole row if only the chevron is focusable — the full question should be the hit target.
Performance notes
- FAQ lists are usually small (10–50). No virtualization needed.
- Avoid mounting heavy media inside every closed panel if answers can contain video; lazy-render panel children only when first opened if content is expensive.
- Memoize item components only if parent re-renders often for unrelated reasons.
Footguns
- Clickable
divheaders — breaks keyboard and AT - Forgetting
type="button"— inside forms, defaults to submit - Single mode that keeps previous open in state array — stale multi-ids
- Animating with
height: autohacks that thrash layout every frame - Using only color for open state — need icon or text change too
Interview out-loud answer
I’d model FAQ items as data with stable ids. Open state is either one id or a set, controlled via a list-of-ids API. Each header is a native button with
aria-expandedandaria-controlspointing at a region panel. MVP is click toggle + single-open; then multi mode, focus styles, and optional arrow-key roving. I’d skip nested accordions unless product requires them.
Extensions they may ask live
- Allow only one open globally across multiple accordion groups
- Persist open sections in
sessionStorage - Expand all / collapse all controls
- Lazy-load answer HTML from an API when first expanded
Related on this site
- Accessible Modal Dialog
- Tabs Component
- Machine Coding Interview Framework
- Accessibility Interview Talking Points