frontend / accessibility / 06_accessible_components.md

Accessible Components (APG Patterns)

4 min read source

Accessible Components (APG Patterns)

TL;DR

The WAI-ARIA Authoring Practices Guide (APG) specifies the role/state markup and the keyboard interaction for common widgets — dialog, disclosure, tabs, menu, combobox. The senior takeaway: these are genuinely hard to get right (focus, keyboard, ARIA state, screen-reader announcement all at once), so use an accessible primitive library (Radix, React Aria, Headless UI) rather than hand-rolling — but know the patterns so you can verify them. See ../08_component_libraries/headless_ui_and_radix.md.

Interview Q&A

Q: What does an accessible modal dialog require?

A: Markup + behavior:

  • role="dialog" (or native <dialog>), aria-modal="true", named via aria-labelledby (its title).
  • On open: move focus into the dialog; trap focus inside it; mark the rest of the page inert/aria-hidden.
  • Escape closes it; on close restore focus to the trigger (04_keyboard_and_focus.md).

The native <dialog> element with .showModal() handles trap + Escape + top-layer + backdrop for you — prefer it or a vetted library.

Q: Tabs — markup and keyboard?

A:

<div role="tablist" aria-label="Account">
  <button role="tab" aria-selected="true"  aria-controls="p1" id="t1">Profile</button>
  <button role="tab" aria-selected="false" aria-controls="p2" id="t2" tabindex="-1">Billing</button>
</div>
<div role="tabpanel" id="p1" aria-labelledby="t1">…</div>
<div role="tabpanel" id="p2" aria-labelledby="t2" hidden>…</div>

Keyboard (roving tabindex — one Tab stop for the list):

Key Action
Tab move into the tablist (active tab), then to the panel
Left/Right (or Up/Down) move between tabs
Home/End first/last tab
Enter/Space activate (if manual activation)

aria-selected tracks the active tab; the active tab has tabindex="0", the rest -1.

Q: Disclosure (show/hide) — the simplest pattern?

A: A button toggling a region:

<button aria-expanded="false" aria-controls="more">Details</button>
<div id="more" hidden>…</div>

Toggle aria-expanded and the hidden/visibility together. Native <details>/<summary> gives this for free — use it unless you need custom animation/behavior.

Q: Why is a combobox/autocomplete the hardest widget?

A: It combines a text input, a popup listbox, async results, and keyboard navigation while focus stays in the input:

  • input: role="combobox", aria-expanded, aria-controls="listbox-id", aria-activedescendant="option-id".
  • popup: role="listbox" with role="option" children; the active option is referenced by aria-activedescendant (focus never leaves the input).
  • keyboard: Down/Up move the active option, Enter selects, Escape closes, typing filters.
  • results announced via the listbox; loading/empty states need a live region.

There are many edge cases (selection models, async, mobile). This is the canonical “don’t hand-roll — use Radix/React Aria/Downshift” widget.

A: A role="menu" (with role="menuitem") is for application menus (actions), navigated with arrow keys and a single Tab stop, opened from a aria-haspopup="menu" button. A site navigation list is just <nav><ul><li><a> — do not slap role="menu" on nav links; that changes the interaction model and confuses users. Most “dropdown menus” in nav are disclosures of links, not ARIA menus.

Q: When do you build vs adopt a library for these?

A: Adopt. Radix Primitives, React Aria (Adobe), Headless UI, and Downshift implement the APG patterns with the focus/keyboard/ARIA correctness that takes months to get right and regresses easily. Build only a trivial disclosure/toggle, and even then prefer native (<details>, <dialog>). Know the patterns to audit the library and your usage. See ../08_component_libraries/headless_ui_and_radix.md.

Gotchas / edge cases

  • role="menu" on navigation — wrong model; reserve menus for action menus, use links/disclosure for nav.
  • hidden vs CSS hiding for tab panels — use hidden/display:none so hidden panels leave the tab order and AT tree.
  • Tooltips on :hover only — must also show on focus and be dismissible (Escape), and not contain interactive content (WCAG 1.4.13).
  • Animated/transitioned dialogs — don’t move focus before the element is actually in the DOM and focusable; respect prefers-reduced-motion.
  • Toasts — auto-dismiss can outrun a screen reader; give enough time or a persistent log; announce via a polite live region.
  • aria-activedescendant vs roving tabindex — combobox uses activedescendant (focus stays in input); tabs/menus use roving tabindex (focus moves to the item). Mixing them up breaks the widget.

What a senior is expected to say

  • “These widgets need role + state + keyboard + focus + announcement together — I use Radix/React Aria/Headless UI and verify against the APG rather than hand-rolling.”
  • “Dialog: aria-modal, labelled, focus trapped, background inert, Escape closes, focus restored. I reach for native <dialog>.”
  • “Tabs/menus use roving tabindex; combobox uses aria-activedescendant. role=menu is for action menus, not nav links.”
  • “Native first — <details>, <dialog>, <select> — before any custom build.”

Cross-references

Further reading