Keyboard accessibility is one of the most important — and most neglected — aspects of web accessibility. An estimated 2.5 million Americans have motor disabilities that prevent mouse use. If your site can't be operated entirely by keyboard, you're excluding them completely.
The Four Core Principles
WCAG 2.2 Principle 2 (Operable) contains the keyboard requirements:
- 2.1.1 Keyboard (AA): All functionality must be operable via keyboard
- 2.1.2 No Keyboard Trap (AA): If focus moves into a component, it must be possible to move it out
- 2.4.3 Focus Order (AA): If page can be navigated sequentially, order must be logical and predictable
- 2.4.7 Focus Visible (AA): Any keyboard-operable UI must have a visible focus indicator
- 2.4.11 Focus Appearance (AA, new in 2.2): Focus indicator must meet size and contrast requirements
Testing Without Automated Tools
Start with the basic keyboard test:
- Unplug (or ignore) your mouse
- Press Tab to move forward through interactive elements
- Press Shift+Tab to move backward
- Use Enter/Space to activate buttons, links, checkboxes
- Use arrow keys for radio groups, menus, sliders
- Use Escape to close dialogs and menus
Any element you can't reach or activate? That's a WCAG 2.1.1 failure.
The Most Common Keyboard Failures
Custom dropdowns and menus
// ❌ Keyboard inaccessible
function Dropdown({ items }) {
return (
<div onClick={toggle} className="dropdown">
{items.map(item => (
<div onClick={() => select(item)}>{item.label}</div>
))}
</div>
);
}
// ✅ Fully keyboard accessible
function Dropdown({ items }) {
return (
<div
role="combobox"
aria-haspopup="listbox"
aria-expanded={isOpen}
tabIndex={0}
onKeyDown={handleKeyDown} // handles Enter, Space, Arrows, Escape
className="dropdown"
>
<ul role="listbox">
{items.map((item, i) => (
<li
key={item.id}
role="option"
tabIndex={-1}
aria-selected={i === activeIndex}
onKeyDown={e => e.key === 'Enter' && select(item)}
>
{item.label}
</li>
))}
</ul>
</div>
);
}
Modals and dialogs
Modal dialogs must:
- Move focus into the dialog when it opens
- Trap focus inside while it's open (Tab cycles within)
- Return focus to the trigger element when it closes
function openModal(modalEl, triggerEl) {
const focusable = modalEl.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
first.focus();
modalEl.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
if (e.key === 'Escape') closeModal(triggerEl);
});
}
Removing default focus styles
The single most common mistake: outline: none in a CSS reset.
/* ❌ Never do this globally */
* { outline: none; }
/* ✅ Remove default, replace with better style */
:focus { outline: none; }
:focus-visible {
outline: 3px solid #0066CC;
outline-offset: 2px;
border-radius: 2px;
}
The :focus-visible pseudo-class shows focus only when navigating by keyboard, not on mouse click — giving you the best of both worlds.
Skip Links
Users navigating by keyboard should be able to skip repetitive navigation. A skip link is the first focusable element in your page:
<a href="#main-content" class="skip-link">Skip to main content</a>
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: #000;
color: #fff;
padding: 8px 16px;
z-index: 9999;
}
.skip-link:focus { top: 0; }
Automated Testing Coverage
Automated scanners can catch about 40% of keyboard accessibility issues — primarily missing tabindex, incorrect roles, and missing focus styles. Tools like AccessiScan provide a starting point with 201 automated checks, but the Tab-through test above is still essential for catching interaction patterns that automation misses.
Top comments (0)