Introduction
Keyboard accessibility is the foundation of web accessibility. Screen reader users, people with motor disabilities, power users, and anyone with a broken mouse all rely on the keyboard. If it doesn't work with a keyboard, it doesn't work for a significant portion of your users.
Every interactive element must be focusable, every action triggerable by keyboard, and focus must be managed logically. This sounds simple, but custom widgets, modals, and dynamic content make it challenging.
This guide covers focus management patterns, tab order, keyboard traps, skip links, and implementing keyboard support for custom widgets.
Key Concepts
Expected Keyboard Interactions
// Standard keyboard patterns:
Tab → Move to next focusable element
Shift + Tab → Move to previous focusable element
Enter/Space → Activate buttons and links
Escape → Close modals, dropdowns, tooltips
Arrow keys → Navigate within widgets (tabs, menus, radio groups)
// Native HTML gets this for free:
<button> → Focusable, Enter/Space activates
<a href> → Focusable, Enter activates
<input> → Focusable, standard keyboard input
<select> → Focusable, Arrow keys navigate options
Focus Management
// Move focus programmatically
const dialogRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isOpen) {
dialogRef.current?.focus();
}
}, [isOpen]);
// Focus visible styles
button:focus-visible {
outline: 2px solid #3B82F6;
outline-offset: 2px;
}
// Don't hide focus! Only hide on mouse click:
button:focus:not(:focus-visible) {
outline: none;
}
Tab Order
<!-- Natural tab order follows DOM order -->
<!-- ✅ Good: Visual and DOM order match -->
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
<a href="/contact">Contact</a>
</nav>
<!-- ❌ Bad: tabindex > 0 creates unpredictable order -->
<button tabindex="3">Third?</button>
<button tabindex="1">First?</button>
<button tabindex="2">Second?</button>
<!-- Use only: -->
<!-- tabindex="0" → Add to tab order (for custom elements) -->
<!-- tabindex="-1" → Focusable via JS, not in tab order -->
Practical Examples
1. Focus Trap for Modal
function useFocusTrap(ref: RefObject<HTMLElement>, isActive: boolean) {
useEffect(() => {
if (!isActive || !ref.current) return;
const focusable = ref.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0] as HTMLElement;
const last = focusable[focusable.length - 1] as HTMLElement;
function handleKeyDown(e: KeyboardEvent) {
if (e.key !== 'Tab') return;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
ref.current.addEventListener('keydown', handleKeyDown);
first?.focus();
return () => ref.current?.removeEventListener('keydown', handleKeyDown);
}, [isActive, ref]);
}
2. Skip Link
// First element in body — hidden until focused
<a href="#main-content" className="
absolute -top-10 left-4 z-50
bg-white px-4 py-2 font-medium
focus:top-4
transition-all
">
Skip to main content
</a>
<main id="main-content" tabindex="-1">
{/* Page content */}
</main>
3. Roving Tabindex for Tab Panel
function Tabs({ tabs }: { tabs: Tab[] }) {
const [activeIndex, setActiveIndex] = useState(0);
function handleKeyDown(e: React.KeyboardEvent) {
let next = activeIndex;
if (e.key === 'ArrowRight') next = (activeIndex + 1) % tabs.length;
if (e.key === 'ArrowLeft') next = (activeIndex - 1 + tabs.length) % tabs.length;
if (next !== activeIndex) {
setActiveIndex(next);
// Focus the new tab button
(e.currentTarget.children[next] as HTMLElement).focus();
}
}
return (
<>
<div role="tablist" onKeyDown={handleKeyDown}>
{tabs.map((tab, i) => (
<button key={i} role="tab"
aria-selected={i === activeIndex}
tabIndex={i === activeIndex ? 0 : -1}
onClick={() => setActiveIndex(i)}>
{tab.label}
</button>
))}
</div>
<div role="tabpanel">{tabs[activeIndex].content}</div>
</>
);
}
Best Practices
- ✅ Use native interactive elements — they have built-in keyboard support
- ✅ Make focus indicators visible — at least 2px solid, high contrast
- ✅ Implement focus trapping in modals and dialogs
- ✅ Add skip links to bypass repetitive navigation
- ✅ Use roving tabindex for composite widgets (tabs, toolbars, menus)
- ❌ Don't use tabindex > 0 — it creates unpredictable tab order
- ❌ Don't remove focus styles without providing alternatives
Common Pitfalls
- Custom elements (divs with onClick) that can't receive keyboard focus
- Keyboard traps — user can Tab in but can't Tab out (broken focus trap)
- Focus disappearing into invisible elements after DOM changes
- Arrow key navigation not implemented for custom widgets like tabs and menus
Related Guides
- WCAG Practical Guide — Keyboard criteria (2.1.1, 2.1.2, 2.4.7)
- Screen Reader Testing — Keyboard is the screen reader's primary input
- ARIA Attributes Guide — ARIA states for keyboard-driven widgets
- Accessible Navigation Patterns — Keyboard patterns for navigation