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 viaaria-labelledby(its title).- On open: move focus into the dialog; trap focus inside it; mark the rest of the page
inert/aria-hidden. Escapecloses 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"withrole="option"children; the active option is referenced byaria-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.
Q: Menu button vs a list of links — what’s the difference?
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.hiddenvs CSS hiding for tab panels — usehidden/display:noneso hidden panels leave the tab order and AT tree.- Tooltips on
:hoveronly — 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-activedescendantvs 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=menuis for action menus, not nav links.” - “Native first —
<details>,<dialog>,<select>— before any custom build.”
Cross-references
- Focus trap, restoration, roving tabindex: 04_keyboard_and_focus.md
- ARIA roles/states these rely on: 03_aria.md
- Headless primitive libraries: ../08_component_libraries/headless_ui_and_radix.md
- Testing these widgets: 07_testing_accessibility.md
Further reading
- WAI-ARIA Authoring Practices Guide — Patterns: https://www.w3.org/WAI/ARIA/apg/patterns/
- Radix Primitives: https://www.radix-ui.com/primitives
- React Aria (Adobe): https://react-spectrum.adobe.com/react-aria/