Keyboard Navigation
Problem
You build a beautiful custom dropdown using divs and CSS that works great with a mouse, then someone presses Tab and nothing happens. The dropdown gets skipped because divs aren’t focusable by default, Enter does nothing without a keydown handler, and arrow keys scroll the page instead of navigating the list.
I’ve tested interfaces where the only way to close a modal was clicking the X button because there was no Escape key handler, leaving keyboard users trapped with no exit. Hover-to-reveal menus create similar problems since keyboard users can’t hover to reveal hidden content.
Even when elements are technically focusable, the interaction patterns are often wrong. Arrow keys should navigate within lists but developers use Tab for everything, Enter should activate buttons but custom components ignore it entirely. The experience feels broken because it ignores decades of keyboard conventions.
Solution
Every interactive element should be reachable via Tab and operable with keyboard alone. Semantic HTML elements like <button>, <a>, and <input> give you this for free.
For custom widgets, follow established patterns users already know: arrow keys navigate within lists and menus, Enter and Space activate items, Escape closes overlays. The WAI-ARIA Authoring Practices defines expected keyboard behavior for every common widget. Don’t invent new patterns when users already have muscle memory.
Make custom elements focusable with tabindex="0", then add keydown handlers for interactivity. Keep focus indicators visible so keyboard users know where they are; the :focus-visible pseudo-class shows outlines only for keyboard navigation while hiding them for mouse clicks.
If functionality requires mouse-specific interaction like drag-and-drop, always provide a keyboard alternative, such as buttons to move items up or down. An interface that only works with a mouse excludes too many people.
Example
Here’s a custom dropdown that works correctly with keyboards. Arrow keys navigate, Enter selects, Escape closes.
Dropdown with Keyboard Navigation
function Dropdown({ items, onSelect }) {
const [isOpen, setIsOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const handleKeyDown = (e) => {
if (!isOpen && (e.key === ' ' || e.key === 'Enter')) {
setIsOpen(true);
e.preventDefault();
return;
}
if (isOpen) {
if (e.key === 'ArrowDown') {
setSelectedIndex((prev) => Math.min(prev + 1, items.length - 1));
} else if (e.key === 'ArrowUp') {
setSelectedIndex((prev) => Math.max(prev - 1, 0));
} else if (e.key === 'Enter' || e.key === ' ') {
onSelect(items[selectedIndex]);
setIsOpen(false);
} else if (e.key === 'Escape') {
setIsOpen(false);
}
e.preventDefault();
}
};
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}>
{item.label}
</li>
))}
</ul>
)}
</div>
);
}
Tab Panel with Roving Tabindex
Tabs use “roving tabindex” where only the active tab has tabIndex={0} while others have -1. This keeps Tab key focused on the active tab while arrow keys move between tabs.
function TabPanel({ tabs }) {
const [activeTab, setActiveTab] = useState(0);
const handleKeyDown = (e) => {
if (e.key === 'ArrowRight') setActiveTab((activeTab + 1) % tabs.length);
else if (e.key === 'ArrowLeft') setActiveTab((activeTab - 1 + tabs.length) % tabs.length);
else if (e.key === 'Home') setActiveTab(0);
else if (e.key === 'End') setActiveTab(tabs.length - 1);
else return;
e.preventDefault();
};
return (
<div>
<div role="tablist">
{tabs.map((tab, index) => (
<button
key={tab.id}
role="tab"
aria-selected={activeTab === index}
tabIndex={activeTab === index ? 0 : -1}
onClick={() => setActiveTab(index)}
onKeyDown={handleKeyDown}
>
{tab.label}
</button>
))}
</div>
{tabs.map((tab, index) => (
<div key={tab.id} role="tabpanel" hidden={activeTab !== index} tabIndex={0}>
{tab.content}
</div>
))}
</div>
);
}
Modal with Keyboard Support
Modals should close when users press Escape:
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
Common shortcuts like Ctrl+S follow platform conventions users already know:
function Editor() {
const handleKeyDown = (e) => {
const mod = e.ctrlKey || e.metaKey;
if (mod && e.key === 's') { handleSave(); e.preventDefault(); }
if (mod && e.key === 'z' && !e.shiftKey) { handleUndo(); e.preventDefault(); }
if (mod && e.key === 'z' && e.shiftKey) { handleRedo(); e.preventDefault(); }
};
return (
<div onKeyDown={handleKeyDown}>
<textarea placeholder="Press Ctrl+S to save" />
</div>
);
}
Skip Links
Skip links let keyboard users jump past navigation directly to main content:
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>
</>
);
}
Focus Indicators
Using :focus-visible shows outlines for keyboard users while hiding them for mouse clicks:
*:focus { outline: none; }
*:focus-visible { outline: 2px solid #0066cc; outline-offset: 2px; }
@media (prefers-contrast: high) {
*:focus-visible { outline: 3px solid currentColor; }
}
Benefits
- Screen readers and assistive technologies rely on keyboard interfaces. If your keyboard navigation works, assistive tech generally works too.
- Power users work faster with keyboard shortcuts than reaching for the mouse and clicking small targets.
- WCAG Level A requires keyboard operability, making this a legal requirement in many jurisdictions rather than an optional enhancement.
- People with motor disabilities, RSI, or temporary injuries can still use your application since mouse use isn’t possible for everyone.
- Users stay in flow without switching between keyboard and mouse, especially valuable during form filling and data entry.
Tradeoffs
- Custom widgets require extensive work. Semantic HTML gives you keyboard support for free, but divs styled as buttons need manual event handlers to replicate the same behavior.
- ARIA keyboard patterns are complex; a proper listbox needs Home, End, PageUp, PageDown, type-ahead search, and multiple selection modes, each with its own expected behavior.
- Your keyboard shortcuts might conflict with browser shortcuts. Ctrl+S for save requires
preventDefault(), which removes native save-page functionality. - Arrow keys normally scroll the page, so intercepting them for widget navigation can confuse users expecting scroll behavior. Consider carefully when to capture versus propagate key events.
- Roving tabindex is hard to maintain in dynamic lists where items change frequently, requiring careful management of which element has
tabindex="0"versus-1. - Keyboard shortcuts are invisible features; without a help modal or visible hints, users won’t know they exist.
- International keyboard layouts differ from QWERTY. Bracket keys sit in different positions on AZERTY, Dvorak, and other layouts, making some shortcuts awkward or impossible.
- Focus indicators create tension with visual design; indicators visible enough for keyboard users are often more prominent than designers prefer.
- Testing keyboard navigation requires actually using a keyboard since automated scanners can’t tell whether the flow feels natural to real users.
Summary
Keyboard navigation makes your application usable without a mouse by ensuring all interactive elements are focusable, properly ordered, and respond to expected keys. Use semantic HTML for automatic keyboard support, manage focus intentionally, and follow established patterns for complex widgets. Keyboard accessibility benefits everyone: power users and people who physically can’t use a mouse alike.