Keyboard Navigation
Problem
Keyboard users get trapped in modal dialogs with no way to escape, or cannot reach interactive elements hidden behind hover states that require mouse movement. Custom dropdowns, accordions, tree views, and menus built with divs ignore Tab key navigation entirely, making their content inaccessible, while interactive elements created with <div> or <span> instead of semantic HTML lack keyboard operability by default. Drag-and-drop interfaces, sliders, and drawing canvases provide no keyboard alternatives, excluding users who cannot use pointing devices.
Power users who prefer keyboard shortcuts for efficiency are forced to reach for the mouse constantly, significantly slowing them down. Focus indicators are invisible or removed entirely, leaving keyboard users blind to their current position. Custom keyboard handlers fail to follow standard patterns—arrow keys don’t navigate lists, Enter doesn’t activate buttons, and Space doesn’t toggle checkboxes—while tab order doesn’t follow visual layout, causing focus to jump unpredictably across the page.
Solution
Ensure all interactive elements are reachable via Tab key and operable via keyboard alone using standard keys (Enter, Space, Arrow keys, Escape). Use semantic HTML elements (<button>, <a>, <input>) that provide keyboard support by default. For custom components built with divs, add tabindex="0" to make them focusable and implement keyboard event handlers for interaction. Follow ARIA authoring practices patterns for standard widgets - use Arrow keys for menu and list navigation, Enter and Space for activation, Escape for closing overlays. Provide keyboard alternatives for mouse-only interactions like drag-and-drop (toolbar buttons to move items) or drawing (coordinate input fields). Ensure focus indicators are always visible using :focus-visible CSS selector. Maintain logical tab order that matches visual layout by using proper DOM order rather than explicit tabindex values. Support standard keyboard shortcuts and document custom ones clearly. This supports keyboard-only users and assistive technology that relies on keyboard interfaces.
Example
This example demonstrates a custom dropdown that responds to keyboard input, allowing users to navigate with arrow keys and select items with Enter.
Dropdown with Keyboard Navigation
function Dropdown({ items, onSelect }) {
const [isOpen, setIsOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const handleKeyDown = (e) => {
// Open dropdown with Space or Enter
if (!isOpen && (e.key === ' ' || e.key === 'Enter')) {
setIsOpen(true);
e.preventDefault();
return;
}
if (isOpen) {
// Navigate down the list when user presses down arrow
if (e.key === 'ArrowDown') {
setSelectedIndex((prev) => Math.min(prev + 1, items.length - 1));
e.preventDefault();
}
// Navigate up the list when user presses up arrow
if (e.key === 'ArrowUp') {
setSelectedIndex((prev) => Math.max(prev - 1, 0));
e.preventDefault();
}
// Select the current item when user presses Enter or Space
if (e.key === 'Enter' || e.key === ' ') {
onSelect(items[selectedIndex]);
setIsOpen(false);
e.preventDefault();
}
// Close dropdown with Escape
if (e.key === 'Escape') {
setIsOpen(false);
e.preventDefault();
}
}
};
// tabIndex={0} makes the element focusable via keyboard
return (
<div
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
tabIndex={0}
onKeyDown={handleKeyDown}
>
<div className="dropdown-trigger">
{items[selectedIndex].label}
</div>
{isOpen && (
<ul role="listbox">
{items.map((item, index) => (
<li
key={item.id}
role="option"
aria-selected={index === selectedIndex}
className={index === selectedIndex ? 'selected' : ''}
>
{item.label}
</li>
))}
</ul>
)}
</div>
);
}
Tab Panel Navigation
function TabPanel({ tabs }) {
const [activeTab, setActiveTab] = useState(0);
const handleKeyDown = (e, index) => {
// Arrow right: next tab
if (e.key === 'ArrowRight') {
setActiveTab((activeTab + 1) % tabs.length);
e.preventDefault();
}
// Arrow left: previous tab
if (e.key === 'ArrowLeft') {
setActiveTab((activeTab - 1 + tabs.length) % tabs.length);
e.preventDefault();
}
// Home: first tab
if (e.key === 'Home') {
setActiveTab(0);
e.preventDefault();
}
// End: last tab
if (e.key === 'End') {
setActiveTab(tabs.length - 1);
e.preventDefault();
}
};
return (
<div>
<div role="tablist">
{tabs.map((tab, index) => (
<button
key={tab.id}
role="tab"
aria-selected={activeTab === index}
aria-controls={`panel-${tab.id}`}
tabIndex={activeTab === index ? 0 : -1}
onClick={() => setActiveTab(index)}
onKeyDown={(e) => handleKeyDown(e, index)}
>
{tab.label}
</button>
))}
</div>
{tabs.map((tab, index) => (
<div
key={tab.id}
id={`panel-${tab.id}`}
role="tabpanel"
hidden={activeTab !== index}
tabIndex={0}
>
{tab.content}
</div>
))}
</div>
);
}
Accordion with Keyboard Support
function Accordion({ items }) {
const [expandedIds, setExpandedIds] = useState([]);
const handleKeyDown = (e, itemId, index) => {
// Toggle with Space or Enter
if (e.key === ' ' || e.key === 'Enter') {
toggleItem(itemId);
e.preventDefault();
}
// Arrow down: focus next header
if (e.key === 'ArrowDown') {
const nextButton = e.target.parentElement.nextElementSibling?.querySelector('button');
nextButton?.focus();
e.preventDefault();
}
// Arrow up: focus previous header
if (e.key === 'ArrowUp') {
const prevButton = e.target.parentElement.previousElementSibling?.querySelector('button');
prevButton?.focus();
e.preventDefault();
}
};
const toggleItem = (id) => {
setExpandedIds(prev =>
prev.includes(id)
? prev.filter(i => i !== id)
: [...prev, id]
);
};
return (
<div>
{items.map((item, index) => (
<div key={item.id}>
<button
aria-expanded={expandedIds.includes(item.id)}
aria-controls={`content-${item.id}`}
onClick={() => toggleItem(item.id)}
onKeyDown={(e) => handleKeyDown(e, item.id, index)}
>
{item.title}
</button>
{expandedIds.includes(item.id) && (
<div id={`content-${item.id}`}>
{item.content}
</div>
)}
</div>
))}
</div>
);
}
Tree View Navigation
function TreeView({ data }) {
const [expandedNodes, setExpandedNodes] = useState([]);
const [focusedNode, setFocusedNode] = useState(null);
const handleKeyDown = (e, node) => {
switch (e.key) {
// Expand node
case 'ArrowRight':
if (node.children && !expandedNodes.includes(node.id)) {
setExpandedNodes([...expandedNodes, node.id]);
}
e.preventDefault();
break;
// Collapse node
case 'ArrowLeft':
if (expandedNodes.includes(node.id)) {
setExpandedNodes(expandedNodes.filter(id => id !== node.id));
}
e.preventDefault();
break;
// Move to next node
case 'ArrowDown':
focusNextNode(node);
e.preventDefault();
break;
// Move to previous node
case 'ArrowUp':
focusPreviousNode(node);
e.preventDefault();
break;
// Select/activate node
case 'Enter':
case ' ':
onNodeSelect(node);
e.preventDefault();
break;
}
};
return (
<ul role="tree">
{data.map(node => (
<TreeNode
key={node.id}
node={node}
onKeyDown={handleKeyDown}
expanded={expandedNodes.includes(node.id)}
/>
))}
</ul>
);
}
Modal with Keyboard Support
function Modal({ isOpen, onClose, children }) {
useEffect(() => {
const handleEscape = (e) => {
if (e.key === 'Escape' && isOpen) {
onClose();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div
role="dialog"
aria-modal="true"
tabIndex={-1}
>
<h2>Modal Title</h2>
<div>{children}</div>
<button onClick={onClose}>Close</button>
</div>
);
}
Keyboard Shortcuts
function Editor() {
const handleKeyDown = (e) => {
// Ctrl+S: Save
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
handleSave();
e.preventDefault();
}
// Ctrl+Z: Undo
if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
handleUndo();
e.preventDefault();
}
// Ctrl+Shift+Z: Redo
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'z') {
handleRedo();
e.preventDefault();
}
};
return (
<div onKeyDown={handleKeyDown}>
<textarea placeholder="Press Ctrl+S to save" />
</div>
);
}
Skip Links
function Layout({ children }) {
return (
<>
<a href="#main-content" className="skip-link">
Skip to main content
</a>
<nav>
{/* Navigation links */}
</nav>
<main id="main-content" tabIndex={-1}>
{children}
</main>
</>
);
}
Roving Tabindex for Lists
function Toolbar({ items }) {
const [focusedIndex, setFocusedIndex] = useState(0);
const handleKeyDown = (e, index) => {
if (e.key === 'ArrowRight') {
setFocusedIndex((focusedIndex + 1) % items.length);
e.preventDefault();
}
if (e.key === 'ArrowLeft') {
setFocusedIndex((focusedIndex - 1 + items.length) % items.length);
e.preventDefault();
}
};
return (
<div role="toolbar">
{items.map((item, index) => (
<button
key={item.id}
tabIndex={focusedIndex === index ? 0 : -1}
onKeyDown={(e) => handleKeyDown(e, index)}
onFocus={() => setFocusedIndex(index)}
>
{item.label}
</button>
))}
</div>
);
}
Focus Indicators
/* Remove default outline */
*:focus {
outline: none;
}
/* Show custom outline only for keyboard focus */
*:focus-visible {
outline: 2px solid #0066cc;
outline-offset: 2px;
}
/* High contrast mode support */
@media (prefers-contrast: high) {
*:focus-visible {
outline: 3px solid currentColor;
}
}
Benefits
- Makes applications accessible to keyboard-only users and assistive technology that relies on keyboard interfaces like screen readers and switch devices.
- Improves usability for power users who prefer keyboard shortcuts for efficiency, enabling faster workflows than mouse navigation.
- Meets WCAG 2.1 Level A requirement (Success Criterion 2.1.1 Keyboard) for all functionality to be operable through keyboard interface.
- Supports mobile devices with external keyboards and specialized input devices like sip-and-puff switches or mouth sticks.
- Benefits users with motor disabilities, RSI, or temporary injuries that make mouse use difficult or painful.
- Enables users to keep hands on keyboard without context switching to mouse, improving productivity for form filling, data entry, and text-heavy tasks.
Tradeoffs
- Requires additional development time to implement keyboard handlers for custom controls that would get keyboard support automatically with semantic HTML.
- Can be complex for rich interactions like drag-and-drop, multi-select with rubber-band selection, drawing interfaces, or spatial manipulation where keyboard alternatives are non-obvious.
- Needs thorough testing across different keyboard layouts (QWERTY, AZERTY, Dvorak) and assistive technologies (NVDA, JAWS, VoiceOver) to ensure consistency.
- May conflict with browser shortcuts if not carefully designed - Ctrl+S for save conflicts with browser’s save page, requiring preventDefault() which removes browser functionality.
- Arrow key navigation requires preventDefault() to avoid scrolling the page, which can confuse users expecting standard scrolling behavior.
- Implementing ARIA patterns correctly requires deep knowledge of specifications - custom select menus must handle Home, End, PageUp, PageDown, type-ahead, and multiple selection modes.
- Roving tabindex pattern (only one item in list is focusable) is complex to implement and maintain as items are added/removed dynamically.
- Keyboard shortcuts can be hard to discover without visible documentation or help modal, leaving users unaware of available efficiency gains.
- International keyboard layouts may not have keys expected by shortcuts - bracket keys, backslash, or special characters may be in different positions or absent entirely.
- Focus indicators must balance visibility for keyboard users with visual design preferences, often creating tension between accessibility and aesthetic goals.
- Testing requires actual keyboard use across scenarios, not just automated accessibility scanners which cannot verify that keyboard navigation feels natural or follows expected patterns.
- Custom components built with divs require extensive ARIA attributes and keyboard handlers that semantic HTML provides automatically, creating maintenance burden.
- Some interactions like right-click context menus have no keyboard equivalent in standard HTML, requiring custom solutions like shift+F10 or explicit menu buttons.
- Keyboard navigation state (which element has focus, which items are selected) must be managed carefully to avoid losing user position when content updates dynamically.
- Screen readers use different navigation modes (virtual cursor vs focus mode) that behave differently with keyboard events, requiring testing in both modes to ensure consistency.