ESC

Type to search the knowledge base.

Accordion FAQ

Machine-coding brief for an accessible accordion FAQ — single/multi expand, keyboard, ARIA, and clean state.

intermediate4 min read
  • 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 → panel id + role="region" / aria-labelledby
  • Visible focus styles

Should have

  • Controlled + uncontrolled modes (value / defaultValue / onChange)
  • Animate height with prefers-reduced-motion respect
  • Disable chevron animation when reduced motion is on

Nice to have

  • Deep-link open item via ?faq=id or hash
  • Search/filter questions
  • Nested accordion (usually refuse unless asked)

Planning (5 minutes out loud)

  1. Single vs multi — string | null vs Set<string>
  2. Ids — stable ids for ARIA wiring
  3. Header is a button — never a clickable div
  4. MVP — toggle + one-open; then multi + keyboard nav
  5. 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

  1. Clickable div headers — breaks keyboard and AT
  2. Forgetting type="button" — inside forms, defaults to submit
  3. Single mode that keeps previous open in state array — stale multi-ids
  4. Animating with height: auto hacks that thrash layout every frame
  5. 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-expanded and aria-controls pointing 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

  1. Allow only one open globally across multiple accordion groups
  2. Persist open sections in sessionStorage
  3. Expand all / collapse all controls
  4. Lazy-load answer HTML from an API when first expanded

Further reading