duces DOM manipulation overhead and simplifies focus management.
import { useState, useRef, useEffect, KeyboardEvent } from 'react';
interface TypeaheadProps {
options: Array<{ id: string; label: string }>;
onSelect: (id: string) => void;
}
export function TypeaheadSelector({ options, onSelect }: TypeaheadProps) {
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (!isOpen) {
if (e.key === 'ArrowDown' || e.key === 'Enter') {
setIsOpen(true);
setActiveIndex(0);
return;
}
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setActiveIndex(prev => (prev < options.length - 1 ? prev + 1 : 0));
break;
case 'ArrowUp':
e.preventDefault();
setActiveIndex(prev => (prev > 0 ? prev - 1 : options.length - 1));
break;
case 'Enter':
if (activeIndex >= 0) {
onSelect(options[activeIndex].id);
setIsOpen(false);
}
break;
case 'Escape':
setIsOpen(false);
inputRef.current?.focus();
break;
}
};
// Scroll active item into view
useEffect(() => {
if (activeIndex >= 0 && listRef.current) {
const activeElement = listRef.current.children[activeIndex] as HTMLElement;
activeElement?.scrollIntoView({ block: 'nearest' });
}
}, [activeIndex]);
return (
<div className="typeahead-container">
<input
ref={inputRef}
role="combobox"
aria-expanded={isOpen}
aria-controls="typeahead-list"
aria-activedescendant={activeIndex >= 0 ? `option-${options[activeIndex].id}` : undefined}
onKeyDown={handleKeyDown}
onFocus={() => setIsOpen(true)}
/>
{isOpen && (
<ul
ref={listRef}
id="typeahead-list"
role="listbox"
className="typeahead-list"
>
{options.map((option, index) => (
<li
key={option.id}
id={`option-${option.id}`}
role="option"
aria-selected={index === activeIndex}
className={index === activeIndex ? 'is-active' : ''}
onClick={() => {
onSelect(option.id);
setIsOpen(false);
}}
>
{option.label}
</li>
))}
</ul>
)}
</div>
);
}
Rationale: Using aria-activedescendant avoids moving the DOM focus, which prevents screen readers from re-announcing the entire list on every arrow key press. The scrollIntoView logic ensures the active option remains visible, addressing a common usability gap in virtualized or long lists.
2. Focus Trapping in Modals
Modals must trap focus within their boundaries and restore focus to the trigger element upon dismissal. Modern browsers support the inert attribute, which simplifies this by making background content non-interactive.
Implementation: Modal with inert and Focus Restoration
import { useEffect, useRef, ReactNode } from 'react';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
triggerElement: HTMLElement | null;
children: ReactNode;
}
export function AccessibleModal({ isOpen, onClose, triggerElement, children }: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isOpen && modalRef.current) {
// Focus the first focusable element inside the modal
const focusableElements = modalRef.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusableElements.length > 0) {
(focusableElements[0] as HTMLElement).focus();
}
}
}, [isOpen]);
useEffect(() => {
if (!isOpen && triggerElement) {
// Restore focus to the trigger
triggerElement.focus();
}
}, [isOpen, triggerElement]);
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
if (!isOpen) return null;
return (
<div
ref={modalRef}
role="dialog"
aria-modal="true"
onKeyDown={handleKeyDown}
className="modal-overlay"
>
{/* Apply inert to siblings when supported, or use CSS pointer-events */}
<div className="modal-content">
{children}
<button onClick={onClose} aria-label="Close dialog">
Close
</button>
</div>
</div>
);
}
Rationale: The inert attribute is the modern standard for disabling background interaction, reducing the need for complex event listeners that block tabbing. However, for broader compatibility, developers should still implement a focus trap as a fallback. The hook ensures focus is restored to triggerElement, preventing focus loss after modal dismissal.
3. Focus Ring Engineering
WCAG 2.2 2.4.11 Focus Appearance requires focus indicators to have a contrast ratio of at least 3:1 against adjacent colors and a minimum thickness of 2 CSS pixels. Default browser outlines often fail these criteria on complex backgrounds.
Implementation: CSS Variables for Compliant Focus Rings
:root {
--focus-ring-color: #005fcc;
--focus-ring-offset: 2px;
--focus-ring-width: 3px;
--focus-ring-style: solid;
}
/* Apply only when navigating via keyboard */
:focus-visible {
outline: var(--focus-ring-width) var(--focus-ring-style) var(--focus-ring-color);
outline-offset: var(--focus-ring-offset);
border-radius: 2px;
}
/* Ensure contrast on dark backgrounds */
.dark-theme :focus-visible {
--focus-ring-color: #4da6ff;
}
/* Custom components may need box-shadow for complex shapes */
.custom-button:focus-visible {
outline: none;
box-shadow: 0 0 0 var(--focus-ring-width) var(--focus-ring-color),
0 0 0 calc(var(--focus-ring-width) + var(--focus-ring-offset)) var(--focus-ring-color);
}
Rationale: Using :focus-visible ensures mouse users do not see the focus ring, preserving visual design while providing clear feedback for keyboard users. CSS variables allow theme-aware focus styles, ensuring contrast requirements are met across light and dark modes. The box-shadow technique provides a fallback for elements where outline cannot be applied effectively.
4. Skip Navigation
Skip links allow keyboard users to bypass repetitive navigation blocks. The link must be the first focusable element and become visible only when focused.
Implementation: Skip Link Component
export function SkipNavigation() {
return (
<a
href="#main-content"
className="skip-link"
style={{
position: 'absolute',
top: '-100px',
left: 0,
background: '#000',
color: '#fff',
padding: '12px 24px',
zIndex: 9999,
transition: 'top 0.2s ease',
}}
onFocus={(e) => {
e.currentTarget.style.top = '0';
}}
onBlur={(e) => {
e.currentTarget.style.top = '-100px';
}}
>
Skip to main content
</a>
);
}
Rationale: Inline styles are used here for clarity, but in production, these should be managed via CSS classes. The component ensures the link is off-screen by default and moves into view on focus. The onBlur handler hides the link when focus moves away, preventing visual clutter.
Pitfall Guide
-
Tabindex Inflation
- Explanation: Setting
tabindex="0" on non-interactive elements (like div or span) to make them focusable. This clutters the tab order with elements that have no action.
- Fix: Only add
tabindex="0" to custom interactive widgets. Use semantic HTML (<button>, <a>) whenever possible. If an element is not interactive, it should not be in the tab order.
-
Focus Starvation in Dynamic Content
- Explanation: When content updates dynamically (e.g., infinite scroll, tab switch), focus may be lost or moved to an unexpected location.
- Fix: Explicitly manage focus after DOM updates. Move focus to the new content container or the first interactive element within the updated region. Use
aria-live regions to announce changes to screen readers.
-
Focus Trap Leakage
- Explanation: Users can tab out of a modal or overlay into the background content.
- Fix: Implement a focus trap that cycles focus between the first and last focusable elements. Use the
inert attribute on background content where supported. Test by tabbing repeatedly to ensure focus remains contained.
-
Ignoring the Escape Key
- Explanation: Overlays, menus, and dialogs do not close when the user presses
Escape.
- Fix: Always bind the
Escape key to the close/dismiss action for transient UI elements. This is a standard expectation for keyboard users and is required by WCAG for custom widgets.
-
Low-Contrast Focus Indicators
- Explanation: Focus rings that blend into the background or are too thin to be seen.
- Fix: Ensure focus indicators meet the 3:1 contrast ratio and 2px thickness requirements of WCAG 2.4.11. Test focus states against all background colors used in the application.
-
Mouse-Only Event Listeners
- Explanation: Relying solely on
onClick handlers for custom widgets.
- Fix: Implement
onKeyDown handlers for Enter and Space on custom interactive elements. Ensure that role="button" elements respond to keyboard activation just like native buttons.
-
Missing ARIA State Synchronization
- Explanation: ARIA attributes like
aria-expanded or aria-selected are not updated when the UI state changes.
- Fix: Bind ARIA attributes to component state. When a menu opens, update
aria-expanded to true. When an option is selected, update aria-selected. Assistive technologies rely on these attributes to convey state.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Simple Form Controls | Native HTML Elements | Zero implementation effort; maximum browser compatibility; built-in keyboard support. | Low |
| Complex Data Grid | Roving Tabindex Pattern | Allows granular control over cell navigation; supports arrow key movement within the grid. | Medium |
| Command Palette / Search | aria-activedescendant | Reduces DOM focus churn; better performance for large lists; simplifies focus management. | Medium |
| Modal Dialogs | <dialog> Element + inert | Native browser support for focus trapping and background disabling; reduces custom code. | Low |
| Legacy Browser Support | Custom Focus Trap + Polyfills | Ensures operability in older environments; requires manual event handling. | High |
Configuration Template
ESLint Configuration for Accessibility Rules
Integrate these rules into your build pipeline to catch common keyboard accessibility violations early.
{
"extends": [
"plugin:jsx-a11y/recommended"
],
"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",
"jsx-a11y/aria-activedescendant-has-tabindex": "error",
"jsx-a11y/iframe-has-title": "error",
"jsx-a11y/mouse-events-have-key-events": "warn"
}
}
Quick Start Guide
- Install Accessibility Linting: Add
eslint-plugin-jsx-a11y to your project and configure the rules above to flag keyboard interaction issues during development.
- Add Skip Navigation: Implement a
SkipNavigation component at the root of your application layout. Ensure it targets the main content region.
- Run Manual Tab Test: Unplug your mouse and navigate through your critical user flows. Document any elements that cannot be reached or activated.
- Fix Focus Styles: Update your CSS to use
:focus-visible with variables that meet WCAG 2.4.11 contrast and thickness requirements.
- Validate Modals: Test all modal dialogs for focus trapping and restoration. Ensure
Escape closes the dialog and focus returns to the trigger.