- React
- Frontend Security, Taint APIs, and Web Accessibility
- Accessible Components: Keyboard Nav & Focus Management
Accessible Components: Keyboard Nav & Focus Management
Accessible React components comply with WAI-ARIA authoring practices by utilizing semantic HTML, proper ARIA states (aria-expanded, aria-selected), managing keyboard events (Arrow navigation, Escape, Enter), and programmatic focus management via useRef and focus() to guarantee screen reader and keyboard parity.
Key Learning Objectives
Implement WAI-ARIA attributes (aria-expanded, aria-controls, aria-haspopup, role) correctly in React.
Manage focus programmatically using useRef and .focus() on component mount, toggle, and dismiss.
Build keyboard roving tabindex patterns for accessible menus and radio groups.
Restore focus to the opening trigger element when overlays or dropdowns close.
The Interview Problem & Context
Build an accessible custom Dropdown Menu component that supports keyboard navigation (Escape, ArrowUp, ArrowDown), announces states with ARIA, and restores focus on close.
1import React, { useState, useRef, useEffect, KeyboardEvent } from 'react';23interface DropdownProps {4 label: string;5 items: Array<{ id: string; label: string; onSelect: () => void }>;6}78export function AccessibleDropdown({ label, items }: DropdownProps) {9 const [isOpen, setIsOpen] = useState(false);10 const [activeIndex, setActiveIndex] = useState(0);1112 const triggerRef = useRef<HTMLButtonElement>(null);13 const menuRef = useRef<HTMLUListElement>(null);1415 // 1. Focus active item when menu opens16 useEffect(() => {17 if (isOpen) {18 const activeEl = menuRef.current?.children[activeIndex] as HTMLElement;19 activeEl?.focus();20 } else {21 // Restore focus to trigger button when closed22 triggerRef.current?.focus();23 }24 }, [isOpen]);2526 // 2. Keyboard Navigation27 const handleKeyDown = (e: KeyboardEvent) => {28 if (!isOpen) {29 if (['ArrowDown', 'Enter', ' '].includes(e.key)) {30 e.preventDefault();31 setIsOpen(true);32 }33 return;34 }3536 switch (e.key) {37 case 'ArrowDown':38 e.preventDefault();39 setActiveIndex((prev) => (prev + 1) % items.length);40 break;41 case 'ArrowUp':42 e.preventDefault();43 setActiveIndex((prev) => (prev - 1 + items.length) % items.length);44 break;45 case 'Escape':46 e.preventDefault();47 setIsOpen(false);48 break;49 case 'Enter':50 case ' ':51 e.preventDefault();52 items[activeIndex].onSelect();53 setIsOpen(false);54 break;55 }56 };5758 return (59 <div className="dropdown-container" onKeyDown={handleKeyDown}>60 {/* Semantic button with full ARIA announcement */}61 <button62 ref={triggerRef}63 aria-haspopup="menu"64 aria-expanded={isOpen}65 onClick={() => setIsOpen((prev) => !prev)}66 className="dropdown-trigger"67 >68 {label}69 </button>7071 {isOpen && (72 <ul ref={menuRef} role="menu" className="dropdown-menu">73 {items.map((item, index) => (74 <li75 key={item.id}76 role="menuitem"77 tabIndex={activeIndex === index ? 0 : -1} // Roving tabindex78 onClick={() => {79 item.onSelect();80 setIsOpen(false);81 }}82 className={activeIndex === index ? 'focused' : ''}83 >84 {item.label}85 </li>86 ))}87 </ul>88 )}89 </div>90 );91}
Deep Architectural Explanation
The Pillars of Accessible React Components
1. Semantic Markup & ARIA Contracts:
aria-haspopup="menu": Informs assistive technology that activating the button reveals a menu.aria-expanded={isOpen}: Screen readers announce "expanded" or "collapsed" whenever state toggles.role="menu"androle="menuitem": Defines the widget role hierarchy according to WAI-ARIA authoring practices.
2. Keyboard Parity:
A mouse user clicks; a keyboard user requires:
- Enter / Space / ArrowDown: Opens the dropdown.
- ArrowDown / ArrowUp: Moves focus between items with wrap-around.
- Escape: Closes the dropdown.
- Enter: Activates the selected item.
3. Focus Restoration:
When the menu closes, focus must return to triggerRef.current. If focus is dropped, the browser resets focus to document.body, forcing the user to tab through the entire website again!
4. Roving tabindex Pattern:
Only the currently active item has tabIndex={0}; all other items have tabIndex={-1}. This allows the user to press Tab to jump out of the menu in a single keystroke rather than tabbing through 20 items.
Common Interview Traps & Pitfalls
Using `<div onClick={...}>` instead of a semantic `<button>`, losing keyboard focusability and Enter/Space activation.
Failing to restore focus to the trigger button when an overlay or menu closes.
Putting `tabIndex={0}` on every item in a dropdown, forcing keyboard users to tab through every single item.
Senior Interviewer Follow-Up Probes
How does the WAI-ARIA combobox pattern differ from a simple menu dropdown?
How do screen readers announce dynamic content updates using `aria-live="polite"` vs `aria-live="assertive"`?
What automated testing tools (e.g. axe-core, jest-axe) integrate with React Testing Library?
Frequently Asked Interview Questions
Why must focus be restored when an interactive overlay closes?
If focus is not restored to the originating trigger element, focus defaults to the document body or top of page, disorienting keyboard and screen reader users.
What is the difference between aria-label and aria-labelledby?
aria-label takes a direct string value for elements lacking visible text (like an icon button). aria-labelledby takes the ID of another visible DOM element that serves as the label.
Why should clickable elements always be semantic buttons or links?
Native <button> and <a> elements provide built-in focusability, keyboard triggers (Enter/Space), disabled states, and correct screen reader announcements without custom event listeners.
What does aria-expanded indicate?
It communicates whether a collapsible element (like an accordion, dropdown, or drawer) is currently open (true) or closed (false).
How do you test accessibility in React components?
Use jest-axe for automated unit tests, eslint-plugin-jsx-a11y for linting, Chrome Lighthouse / Axe DevTools for audits, and manual keyboard and VoiceOver/NVDA screen reader testing.
