}, []);
useEffect(() => {
if (!isActive || !containerRef.current) return;
const focusableElements = getFocusableElements();
if (focusableElements.length === 0) return;
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
// Move focus to the first interactive element
firstElement.focus();
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && onEscape) {
event.preventDefault();
onEscape();
return;
}
if (event.key !== 'Tab') return;
const activeElement = document.activeElement as HTMLElement;
if (event.shiftKey) {
// Shift+Tab: Move focus to last element if at first
if (activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
}
} else {
// Tab: Move focus to first element if at last
if (activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isActive, getFocusableElements, onEscape]);
return containerRef;
}
**Rationale:**
* **Encapsulation:** The hook isolates focus logic, reducing duplication and ensuring all modals behave consistently.
* **Dynamic Filtering:** The selector excludes disabled elements and hidden elements (`offsetParent !== null`), preventing focus from landing on non-interactive nodes.
* **Escape Handling:** The hook supports an optional `onEscape` callback, allowing components to define custom close behaviors while maintaining standard keyboard expectations.
#### 2. Accessible Custom Select Component
Custom dropdowns are a frequent source of keyboard failures. A compliant implementation must support arrow key navigation, Enter/Space activation, and Escape to close. The component should use the `combobox` pattern with `aria-activedescendant` to manage focus without moving the DOM focus for every option.
**Implementation: TypeaheadSelect**
```typescript
import { useState, useRef, useEffect } from 'react';
interface Option {
id: string;
label: string;
}
interface TypeaheadSelectProps {
options: Option[];
onSelect: (option: Option) => void;
placeholder?: string;
}
export function TypeaheadSelect({ options, onSelect, placeholder = 'Select...' }: TypeaheadSelectProps) {
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const [selectedOption, setSelectedOption] = useState<Option | null>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const handleKeyDown = (event: React.KeyboardEvent) => {
if (!isOpen) {
if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown') {
event.preventDefault();
setIsOpen(true);
setActiveIndex(0);
}
return;
}
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
setActiveIndex((prev) => (prev < options.length - 1 ? prev + 1 : prev));
break;
case 'ArrowUp':
event.preventDefault();
setActiveIndex((prev) => (prev > 0 ? prev - 1 : prev));
break;
case 'Enter':
case ' ':
event.preventDefault();
if (activeIndex >= 0) {
handleSelect(options[activeIndex]);
}
break;
case 'Escape':
event.preventDefault();
setIsOpen(false);
triggerRef.current?.focus();
break;
}
};
const handleSelect = (option: Option) => {
setSelectedOption(option);
onSelect(option);
setIsOpen(false);
triggerRef.current?.focus();
};
// Scroll active option into view
useEffect(() => {
if (isOpen && activeIndex >= 0 && listRef.current) {
const activeItem = listRef.current.children[activeIndex] as HTMLElement;
activeItem?.scrollIntoView({ block: 'nearest' });
}
}, [activeIndex, isOpen]);
return (
<div className="select-container">
<button
ref={triggerRef}
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-controls="select-listbox"
onKeyDown={handleKeyDown}
onClick={() => setIsOpen((prev) => !prev)}
>
{selectedOption?.label || placeholder}
</button>
{isOpen && (
<ul
id="select-listbox"
ref={listRef}
role="listbox"
aria-activedescendant={`option-${activeIndex}`}
>
{options.map((option, index) => (
<li
key={option.id}
id={`option-${index}`}
role="option"
aria-selected={index === activeIndex}
onClick={() => handleSelect(option)}
onMouseEnter={() => setActiveIndex(index)}
>
{option.label}
</li>
))}
</ul>
)}
</div>
);
}
Rationale:
aria-activedescendant: This pattern keeps focus on the trigger button while indicating the active option via ID reference. This is more efficient than moving focus to each option and is the recommended pattern for comboboxes.
- Scroll Management: The
useEffect hook ensures the active option is visible, which is critical for long lists.
- Mouse/Keyboard Parity: The component handles both mouse hover and keyboard navigation, updating the active index consistently.
3. Focus Visual Design Tokens
WCAG 2.4.11 requires focus indicators to have a minimum area and contrast ratio. Relying on browser defaults is risky, as they vary across platforms. Define design tokens for focus rings to ensure consistency and compliance.
Implementation: CSS Variables
:root {
--focus-ring-color: #005fcc;
--focus-ring-width: 3px;
--focus-ring-offset: 2px;
--focus-ring-style: solid;
}
/* Apply focus-visible to all interactive elements */
:focus-visible {
outline: var(--focus-ring-width) var(--focus-ring-style) var(--focus-ring-color);
outline-offset: var(--focus-ring-offset);
}
/* Ensure focus ring has sufficient contrast against background */
@media (forced-colors: active) {
:focus-visible {
outline-color: Highlight;
}
}
Rationale:
:focus-visible: This pseudo-class displays the focus ring only for keyboard navigation, avoiding the visual clutter of focus rings on mouse clicks.
- Design Tokens: Centralizing focus styles allows for easy updates and ensures all components share the same visual language.
- Forced Colors Mode: The media query ensures the focus ring remains visible in high-contrast modes, addressing edge cases where custom colors might clash with system themes.
Pitfall Guide
1. The "Outline: None" Reset Trap
Explanation: Many CSS resets globally apply outline: none to remove default browser focus styles. This eliminates all visible focus indicators, violating WCAG 2.4.7 and 2.4.11.
Fix: Never remove outlines globally. Use :focus-visible to replace default styles with custom, compliant focus rings. Always provide a visible indicator for keyboard users.
2. Focus Leaks in Modals
Explanation: When a modal opens, focus may remain on the underlying page, allowing keyboard users to interact with background content. This breaks the modal's purpose and confuses screen reader users.
Fix: Implement a focus trap that cycles focus within the modal. Use the inert attribute on the background content to prevent interaction, or manage aria-hidden states carefully. Ensure focus returns to the trigger element upon closing.
3. Arrow Key Scope Conflicts
Explanation: Global arrow key handlers can interfere with native scrolling or other components. For example, a global ArrowDown handler might prevent the page from scrolling when a dropdown is closed.
Fix: Scope arrow key handling to the active component. Only capture arrow events when a widget is open or focused. Use event.stopPropagation() judiciously and ensure native behaviors are preserved when widgets are inactive.
4. Dynamic Content Focus Loss
Explanation: AJAX updates or route changes can cause focus to reset to the body or disappear entirely. Users lose their place in the interface, requiring them to tab through the entire page again.
Fix: Manage focus explicitly after dynamic updates. Move focus to the updated region, a status message, or the first interactive element of the new view. Use aria-live regions to announce changes without stealing focus when appropriate.
5. tabindex="0" Sprinkling
Explanation: Developers often add tabindex="0" to non-interactive elements to make them focusable, without implementing keyboard event handlers. This creates "focus traps" where users can tab to an element but cannot activate it.
Fix: Only use tabindex="0" on elements that have corresponding keyboard interactions. Prefer native semantic elements over generic containers. If an element is interactive, it should be a <button>, <a>, or have a role with appropriate key handlers.
6. Ignoring WCAG 2.4.11 Focus Appearance
Explanation: Many implementations provide a focus ring that is too thin or has insufficient contrast. WCAG 2.2 requires a focus indicator with a contrast ratio of at least 3:1 and a minimum area of 3 CSS pixels.
Fix: Audit focus rings against the new criteria. Ensure the focus indicator is visible against all background colors. Use thick outlines or box-shadows to meet the size requirement. Test focus visibility in both light and dark modes.
7. Missing Escape Key Handling
Explanation: Users expect the Escape key to close menus, modals, and overlays. Omitting this handler forces users to tab through all elements to exit a component, which is inefficient and frustrating.
Fix: Implement Escape key handling in all overlay components. Ensure the key closes the component and returns focus to the trigger element. Document this behavior in component APIs to ensure consistency across the application.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Simple Selection | Native <select> | Provides full keyboard support and screen reader integration out of the box. | Low |
| Complex Grid | Custom Grid with roving tabindex | Native tables lack interactivity; custom grid allows cell-level navigation. | High |
| Modal Dialog | <dialog> Element | Native focus trapping and ESC handling; reduces custom code. | Low |
| Custom Dropdown | combobox Pattern | Required when search or filtering is needed; ensures operability. | Medium |
| Button Action | <button> | Native activation, focus, and ARIA support; avoids div-based pitfalls. | Low |
Configuration Template
ESLint Configuration for Accessibility
{
"extends": [
"plugin:jsx-a11y/recommended"
],
"plugins": [
"jsx-a11y"
],
"rules": {
"jsx-a11y/no-autofocus": "warn",
"jsx-a11y/anchor-is-valid": "error",
"jsx-a11y/click-events-have-key-events": "error",
"jsx-a11y/no-static-element-interactions": "error",
"jsx-a11y/role-has-required-aria-props": "error"
}
}
CSS Focus Reset Template
/* Reset default outlines only for mouse users */
:focus:not(:focus-visible) {
outline: none;
}
/* Apply compliant focus styles for keyboard users */
:focus-visible {
outline: 3px solid var(--focus-ring-color, #005fcc);
outline-offset: 2px;
border-radius: 2px;
}
/* Support for forced colors mode */
@media (forced-colors: active) {
:focus-visible {
outline-color: Highlight;
}
}
Quick Start Guide
- Install Accessibility Linter: Add
eslint-plugin-jsx-a11y to your project to catch common keyboard and ARIA issues during development.
- Apply Focus Tokens: Copy the CSS Focus Reset Template into your global stylesheet to ensure all interactive elements have compliant focus indicators.
- Run Manual Audit: Unplug your mouse and navigate the application using only the keyboard. Document any elements that cannot be reached or activated.
- Fix Critical Failures: Address focus traps, missing focus indicators, and broken keyboard interactions identified in the audit.
- Integrate CI Checks: Configure your CI pipeline to run axe-core scans on key pages to prevent regression of keyboard accessibility.