Pattern Accessibility Beginner
Keyboard Navigation
Every interactive element should be focusable and usable with the keyboard, with a logical tab order and the arrow key behavior that users expect.
The problem
You might build a beautiful custom dropdown with divs and CSS that works great with a mouse, and then someone presses Tab and nothing happens. The browser skips the dropdown, because divs aren’t focusable by default. Even once it has focus, Enter does nothing without a keydown handler, and the arrow keys scroll the page instead of moving through the list.
The pattern
Every interactive element you build should be focusable with Tab and usable with just the keyboard. Semantic HTML elements like <button>, <a>, and <input> give you this without any extra work.
Problem
I’ve tested interfaces where the only way to close a modal was clicking the X button, because there was no Escape key handler, so keyboard users were stuck inside it. Menus that only appear on hover cause the same kind of problem, because keyboard users can’t hover to make them appear.
Elements that are technically focusable often still respond to the wrong keys. The arrow keys should move through lists, but some developers use Tab for everything. Enter should activate buttons, but many custom components don’t respond to it at all. The experience feels broken, because these components don’t follow keyboard conventions that people have relied on for decades.
Solution
Custom widgets work best when they follow patterns that users already know, such as the arrow keys moving through lists and menus, and Escape closing overlays. The WAI-ARIA Authoring Practices define the expected keyboard behavior for common widgets. If you invent a new pattern instead, users have to work against habits they’ve built up on other sites.
You can make a custom element focusable with tabindex="0", and add the interactivity with keydown handlers. Focus indicators need to stay visible so keyboard users know where they are. The :focus-visible pseudo-class helps with this, because it matches when someone is moving around with the keyboard, but not after a mouse click.
If any of your features depend on a mouse-specific interaction like drag-and-drop, you’ll need a keyboard alternative too, such as buttons to move items up or down.
Example
This custom dropdown works correctly with a keyboard. The arrow keys move through the options, Enter selects one, and Escape closes the list.
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);
} else {
return; // let Tab and other keys behave normally
}
e.preventDefault();
}
};
const listboxId = useId();
return (
<div
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-controls={listboxId}
aria-activedescendant={isOpen ? `${listboxId}-${selectedIndex}` : undefined}
tabIndex={0}
onKeyDown={handleKeyDown}
>
<div className="dropdown-trigger">{items[selectedIndex].label}</div>
{isOpen && (
<ul role="listbox" id={listboxId}>
{items.map((item, index) => (
<li
key={item.id}
id={`${listboxId}-${index}`}
role="option"
aria-selected={index === selectedIndex}
>
{item.label}
</li>
))}
</ul>
)}
</div>
);
}
Tab Panel with Roving Tabindex
Tabs use a technique called “roving tabindex”, where only the active tab has tabIndex={0} and the others have -1. Pressing Tab moves focus to the active tab, and the arrow keys move between the tabs.
function TabPanel({ tabs }) {
const [activeTab, setActiveTab] = useState(0);
const tabRefs = useRef([]);
// Roving tabindex only works if focus moves along with the active tab
const selectTab = (index) => {
setActiveTab(index);
tabRefs.current[index]?.focus();
};
const handleKeyDown = (e) => {
if (e.key === 'ArrowRight') selectTab((activeTab + 1) % tabs.length);
else if (e.key === 'ArrowLeft') selectTab((activeTab - 1 + tabs.length) % tabs.length);
else if (e.key === 'Home') selectTab(0);
else if (e.key === 'End') selectTab(tabs.length - 1);
else return;
e.preventDefault();
};
return (
<div>
<div role="tablist">
{tabs.map((tab, index) => (
<button
key={tab.id}
ref={(el) => (tabRefs.current[index] = el)}
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
A modal should close when the user presses 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 for save follow platform conventions that users already know.
function Editor({ onSave, onUndo, onRedo }) {
const handleKeyDown = (e) => {
const mod = e.ctrlKey || e.metaKey;
// Shift turns e.key into 'Z', so compare lowercase
const key = e.key.toLowerCase();
if (mod && key === 's') { onSave(); e.preventDefault(); }
if (mod && key === 'z' && !e.shiftKey) { onUndo(); e.preventDefault(); }
if (mod && key === 'z' && e.shiftKey) { onRedo(); e.preventDefault(); }
};
return (
<div onKeyDown={handleKeyDown}>
<textarea placeholder="Press Ctrl+S to save" />
</div>
);
}
Skip Links
A skip link lets keyboard users jump past the navigation straight to the 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
With :focus-visible, keyboard users get an outline, and mouse users don’t see one after a click.
*:focus { outline: none; }
*:focus-visible { outline: 2px solid #0066cc; outline-offset: 2px; }
@media (prefers-contrast: more) {
*:focus-visible { outline: 3px solid currentColor; }
}
Benefits
- Screen readers and other assistive technologies rely on keyboard interfaces, so working keyboard navigation is a large part of supporting them.
- Power users can work faster with keyboard shortcuts than with a mouse and small click targets.
- Keyboard operability is a WCAG Level A requirement, success criterion 2.1.1. Regulations such as Section 508 in the US, the DOJ’s ADA Title II rule, and the European Accessibility Act, through EN 301 549, require WCAG Level AA, which includes it, so for many organizations, it’s a legal obligation.
- People with motor disabilities or temporary injuries who can’t use a mouse can still use your application.
- Users can keep working without switching between the keyboard and the mouse, which helps most when they’re filling in forms or entering data.
Tradeoffs
- Custom widgets take a lot of work. Semantic HTML gives you keyboard support without extra code, but a div styled as a button needs its own event handlers to copy the same behavior.
- ARIA keyboard patterns can be complex. A full listbox can need Home, End, PageUp, PageDown, type-ahead search, and multiple selection modes, and each one has its own expected behavior.
- Your keyboard shortcuts might conflict with browser shortcuts. To use Ctrl+S for save, you have to call
preventDefault(), and that stops the browser’s own save-page shortcut from working. - Arrow keys normally scroll the page, so intercepting them for widget navigation can confuse users who expect the page to scroll. You need to decide carefully when to capture key events and when to let them propagate.
- Roving tabindex is hard to maintain in lists where items change often, because you have to keep track of which element has
tabindex="0"and which ones have-1. - Users can’t see keyboard shortcuts, so without a help modal or visible hints, they probably won’t know the shortcuts exist.
- International keyboard layouts differ from QWERTY. Bracket keys are in different positions on AZERTY and other layouts, so some shortcuts can be awkward or impossible to press.
- Focus indicators can clash with the visual design, because an indicator that’s visible enough for keyboard users is often more prominent than designers would like.
- To test keyboard navigation, you have to actually use a keyboard, because automated scanners can’t check whether the flow feels natural to real users.
Summary
With keyboard navigation, people can use your application without a mouse, because all the interactive elements are focusable in a logical order and respond to the keys that users expect. Semantic HTML gives you keyboard support automatically, and complex widgets work best when they follow established patterns. Good keyboard support helps power users as well as people who physically can’t use a mouse.
See also
- Focus Management — Accessibility
- Focus Trap — Accessibility
- ARIA Roles and Attributes — Accessibility
- Keyboard Navigation - MDN
- Keyboard Accessibility - W3C
- WAI-ARIA Keyboard Patterns - W3C