Keyboard Navigation: Accessible Interaction Patterns
Easy•
Keyboard navigation is essential for accessibility - test your app without a mouse.
Quick Navigation: Focus Management • Keyboard Event Patterns • Arrow Key Navigation • Focus Trap (Modals) • Testing & Best Practices
Quick Decision Guide
Keyboard Navigation Essentials:
1. Tab order: All interactive elements reachable via Tab 2. Focus visible: Clear focus indicator (outline) 3. Standard keys: Enter/Space for buttons, Escape for modals 4. Arrow keys: For menus, dropdowns, sliders 5. Focus management: Trap focus in modals, restore after close
Tools: Tab key, focus-visible, tabindex, keyboard event handlers
Result: Fully usable without mouse.
Focus Management
Tab Index
<!-- tabindex="0": In natural tab order -->
<div tabindex="0" role="button">Focusable</div>
<!-- tabindex="-1": Programmatically focusable only -->
<div tabindex="-1" ref={modalRef}>
Modal (focused when opened)
</div>
<!-- tabindex="1+": DON'T USE (breaks tab order) -->
<button tabindex="2">Don't do this</button>Native Focusable Elements
<!-- These are focusable by default (no tabindex needed) -->
<a href="">
<button>
<input>
<select>
<textarea>
<summary> (in <details>)
<!-- These are NOT focusable -->
<div>
<span>
<p>Focus Indicators
/* ❌ Bad: Removing focus indicator */
button:focus {
outline: none; /* NEVER do this! */
}
/* ✅ Good: Custom but visible */
button:focus {
outline: 2px solid #0070f3;
outline-offset: 2px;
}
/* ✅ Better: Only for keyboard (not mouse clicks) */
button:focus-visible {
outline: 2px solid #0070f3;
outline-offset: 2px;
}
/* No outline when clicked, only when tabbed */
button:focus:not(:focus-visible) {
outline: none;
}Programmatic Focus
function Modal({ isOpen }) {
const modalRef = useRef();
useEffect(() => {
if (isOpen) {
modalRef.current?.focus();
}
}, [isOpen]);
return (
<div ref={modalRef} tabIndex={-1}>
Modal content
</div>
);
}Keyboard Event Patterns
Button Activation
// Native button (handles Enter/Space automatically)
<button onClick={handleClick}>
Click me
</button>
// Custom interactive element
function CustomButton({ onClick, children }) {
const handleKeyDown = (e) => {
// Space or Enter activates button
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault(); // Prevent scroll on Space
onClick(e);
}
};
return (
<div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={handleKeyDown}
>
{children}
</div>
);
}Escape to Close
function Modal({ isOpen, onClose }) {
useEffect(() => {
const handleEscape = (e) => {
if (e.key === 'Escape') {
onClose();
}
};
if (isOpen) {
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}
}, [isOpen, onClose]);
return isOpen && (
<div role="dialog" aria-modal="true">
Modal content
</div>
);
}Toggle with Space/Enter
function Checkbox({ checked, onChange, label }) {
const handleKeyDown = (e) => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
onChange(!checked);
}
};
return (
<div
role="checkbox"
aria-checked={checked}
tabIndex={0}
onClick={() => onChange(!checked)}
onKeyDown={handleKeyDown}
>
{label}
</div>
);
}Focus Trap (Modals)
Trap Focus in Modal
function Modal({ isOpen, onClose, children }) {
const modalRef = useRef();
const previousFocusRef = useRef();
useEffect(() => {
if (isOpen) {
// Save currently focused element
previousFocusRef.current = document.activeElement;
// Focus modal
modalRef.current?.focus();
// Cleanup: restore focus
return () => {
previousFocusRef.current?.focus();
};
}
}, [isOpen]);
const handleKeyDown = (e) => {
if (e.key === 'Escape') {
onClose();
return;
}
if (e.key === 'Tab') {
const focusableElements = modalRef.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (e.shiftKey) {
// Shift+Tab: If on first, go to last
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
}
} else {
// Tab: If on last, go to first
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
}
};
if (!isOpen) return null;
return (
<div
ref={modalRef}
role="dialog"
aria-modal="true"
tabIndex={-1}
onKeyDown={handleKeyDown}
>
{children}
</div>
);
}Using react-focus-lock
import FocusLock from 'react-focus-lock';
function Modal({ isOpen, onClose, children }) {
if (!isOpen) return null;
return (
<FocusLock returnFocus>
<div role="dialog" aria-modal="true">
{children}
</div>
</FocusLock>
);
}Testing & Best Practices
Keyboard Testing Checklist
✅ Tab through entire page
•All interactive elements focusable?
•Focus indicator visible?
•Logical tab order?
✅ Test without mouse
•Can you complete all tasks?
•Are all features accessible?
✅ Standard keys work
•Enter/Space activate buttons
•Escape closes modals
•Arrow keys navigate menus
✅ Focus management
•Focus trapped in modals?
•Focus restored after modal close?
•Focus moves to appropriate element after delete?
Keyboard Shortcuts Reference
Tab - Next focusable element
Shift+Tab - Previous focusable element
Enter - Activate links, buttons, submit forms
Space - Activate buttons, check checkboxes
Escape - Close dialogs, cancel actions
Arrow keys - Navigate menus, lists, tabs, sliders
Home - First item in list/menu
End - Last item in list/menu
Page Up/Down - Scroll contentCommon Mistakes
// ❌ No keyboard support
<div onClick={handleClick}>Click me</div>
// ❌ No focus indicator
button:focus { outline: none; }
// ❌ Wrong tab order
<button tabindex="3">Third</button>
<button tabindex="1">First</button>
// ✅ Proper keyboard support
<button onClick={handleClick}>
Click me
</button>
// ✅ Visible focus
button:focus-visible {
outline: 2px solid blue;
}
// ✅ Natural tab order
<button>First</button>
<button>Second</button>