FSD Frontend System Design

Part V β€” The Craft Β· Lesson 10.8

Accordion

In one lineA list of disclosure toggles β€” trivial to render, and a complete accessibility contract to get right.

Where you've seen itAny FAQ page, and every settings panel with collapsible sections.

  • Time14 min
  • DiagramOpen/close state machine + the ARIA wiring
  • Chapter10 Β· Low Level Design

Diagram

diagram
TWO MODES β€” decide before you write state

  SINGLE (radio-like)              MULTIPLE (checkbox-like)
  openId: 'q2'                     openIds: Set{'q1','q3'}

  β”Œβ”€ q1 ─────────────┐ β–Ό           β”Œβ”€ q1 ─────────────┐ β–²
  β”œβ”€ q2 ────────────── β–²           β”‚  answer text     β”‚
  β”‚  answer text     β”‚             β”œβ”€ q2 ────────────── β–Ό
  β”œβ”€ q3 ────────────── β–Ό           β”œβ”€ q3 ────────────── β–²
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜             β”‚  answer text     β”‚
                                   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
  opening q3 closes q2             each toggles independently
diagram
THE ARIA WIRING β€” four attributes, all required

  <h3>
    <button id="hdr-2"
            aria-expanded="true"          ← state, updated on toggle
            aria-controls="panel-2">      ← points at the panel
      What is a service worker?
    </button>
  </h3>
  <div id="panel-2"
       role="region"
       aria-labelledby="hdr-2"            ← names the panel
       hidden={!open}>                    ← hidden, not just CSS
    …
  </div>

  Heading level matters: it puts the accordion in the document
  outline so screen-reader users can navigate by heading (8.3).
diagram
KEYBOARD CONTRACT

  Tab            move between headers (each is a tab stop)
  Enter / Space  toggle the focused section
  Home / End     first / last header        (optional, expected in
  Arrow ↑↓       previous / next header      full ARIA pattern)

  Focus NEVER moves into the panel on open β€” the user chooses (8.4).

How it works

  1. Model state by mode: a single openId or a Set of open ids. Deriving one from the other later is messy β€” decide upfront.
  2. Use a real <button> inside a heading. The heading level places it in the document outline; the button gives you keys and focus for free.
  3. Wire aria-expanded and aria-controls, and hide closed panels with the hidden attribute so they leave the accessibility tree.
  4. Animate height carefully β€” height: auto isn't animatable. Use grid-template-rows: 0fr β†’ 1fr, or measure and animate max-height.
  5. Consider <details>/<summary> for simple cases: the platform gives you the semantics and keyboard behaviour with no JavaScript.
jsx
const [open, setOpen] = useState(() => new Set());
const toggle = (id) => setOpen((prev) => {
  const next = single ? new Set() : new Set(prev);
  prev.has(id) ? next.delete(id) : next.add(id);
  return next;
});

Trade-offs

ChoiceConsequence
Single openFocused reading; more clicking
Multiple openComparison across sections
<details>Free semantics; harder to animate and style
CustomFull control; you own the whole contract
Unmount closed panelsLess DOM; loses internal state

Interview angle

"Build an accordion."

  • Ask single or multiple first, because it decides the state shape.
  • Buttons inside headings, aria-expanded and aria-controls wired, hidden on closed panels, focus staying on the trigger.
  • If the content is heavy, lazy-render panels on first open and keep them mounted afterwards to preserve scroll and form state.

Recap

  • Mode decides the state shape β€” ask before coding.
  • Button-in-heading plus aria-expanded/aria-controls is the whole contract.
  • <details> is free and correct when you don't need custom animation.