Many users navigate the web using only a keyboard—whether by choice or necessity. In Single Page Applications (SPAs), keyboard navigation becomes tricky because of client-side routing and dynamic content. This guide will teach you how to make your SPA fully keyboard accessible with focus management, skip links, and keyboard shortcuts.
Why Keyboard Navigation Matters in SPAs
Traditional websites handle focus automatically on page changes. SPAs don't—you need to manage it yourself:
- Power users - Navigate faster with keyboard shortcuts
- Motor impairments - Can't use a mouse effectively
- Screen reader users - Rely entirely on keyboard navigation
- Legal requirement - WCAG 2.1 mandates keyboard accessibility
- Better UX - Keyboard shortcuts improve everyone's experience
The Basics: Tab Order and Focus Indicators
First, understand how Tab navigation works and how to style it properly:
/* CSS for visible focus indicators */
*:focus-visible {
outline: 3px solid #3b82f6; /* Blue outline */
outline-offset: 2px;
border-radius: 4px;
}
/* Don't remove outlines completely! */
button:focus {
outline: none; /* ❌ Bad - makes site unusable for keyboard users */
}
/* If you must remove, replace with custom style */
button:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.5); /* ✅ Good alternative */
}
/* Skip default focus for mouse users, show for keyboard */
:focus:not(:focus-visible) {
outline: none;
}
Controlling Tab Order
// tabIndex values:
// 0 = In natural tab order
// -1 = Programmatically focusable, but not in tab order
// 1+ = Custom tab order (avoid unless absolutely necessary)
function TabOrderExample() {
return (
<div>
{/* Natural tab order */}
<button tabIndex={0}>First</button>
<button tabIndex={0}>Second</button>
{/* Programmatically focusable, skipped in tab order */}
<div tabIndex={-1} ref={contentRef}>
Content that can be focused programmatically
</div>
{/* ❌ Avoid positive tabIndex - breaks natural order */}
<button tabIndex={3}>Don't do this</button>
</div>
);
}
Focus Management on Route Changes
The biggest challenge in SPAs: focus doesn't automatically move when routes change. You must handle it manually:
// React Router v6 - Focus management
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
function FocusManagement() {
const location = useLocation();
const mainRef = useRef(null);
useEffect(() => {
// Focus main content area on route change
if (mainRef.current) {
mainRef.current.focus();
// Announce route change to screen readers
const pageTitle = document.title;
announceRouteChange(pageTitle);
}
}, [location.pathname]);
return (
<main
ref={mainRef}
tabIndex={-1}
id="main-content"
aria-label="Main content"
style={{ outline: 'none' }}
>
{/* Your route content */}
</main>
);
}
// Announce to screen readers
function announceRouteChange(message) {
const announcement = document.createElement('div');
announcement.setAttribute('role', 'status');
announcement.setAttribute('aria-live', 'polite');
announcement.classList.add('sr-only');
announcement.textContent = `Navigated to ${message}`;
document.body.appendChild(announcement);
setTimeout(() => {
document.body.removeChild(announcement);
}, 1000);
}
// CSS for screen reader only content
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
Complete React Router Example
import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom';
import { useEffect, useRef } from 'react';
function ScrollToTop() {
const { pathname } = useLocation();
useEffect(() => {
window.scrollTo(0, 0);
}, [pathname]);
return null;
}
function FocusManager({ children }) {
const location = useLocation();
const mainRef = useRef(null);
const isFirstMount = useRef(true);
useEffect(() => {
// Skip focus on initial mount
if (isFirstMount.current) {
isFirstMount.current = false;
return;
}
// Focus main content after route change
const timer = setTimeout(() => {
const heading = document.querySelector('h1');
const main = mainRef.current;
if (heading) {
heading.tabIndex = -1;
heading.focus();
heading.removeAttribute('tabindex');
} else if (main) {
main.focus();
}
}, 0);
return () => clearTimeout(timer);
}, [location.pathname]);
return (
<main ref={mainRef} tabIndex={-1} style={{ outline: 'none' }}>
{children}
</main>
);
}
function App() {
return (
<BrowserRouter>
<ScrollToTop />
<FocusManager>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
</Routes>
</FocusManager>
</BrowserRouter>
);
}
Skip Links: Essential for Keyboard Navigation
Skip links let users bypass repetitive navigation and jump straight to main content:
function SkipLinks() {
return (
<div className="skip-links">
<a href="#main-content" className="skip-link">
Skip to main content
</a>
<a href="#navigation" className="skip-link">
Skip to navigation
</a>
<a href="#footer" className="skip-link">
Skip to footer
</a>
</div>
);
}
/* CSS - Show only when focused */
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: #000;
color: #fff;
padding: 8px 16px;
text-decoration: none;
z-index: 9999;
transition: top 0.2s;
}
.skip-link:focus {
top: 0;
}
/* Example layout */
function Layout({ children }) {
return (
<>
<SkipLinks />
<header>
<nav id="navigation">{/* Navigation */}</nav>
</header>
<main id="main-content" tabIndex={-1}>
{children}
</main>
<footer id="footer">{/* Footer */}</footer>
</>
);
}
Implementing Keyboard Shortcuts
Add keyboard shortcuts to improve navigation for power users:
import { useEffect } from 'react';
function useKeyboardShortcuts(shortcuts) {
useEffect(() => {
const handleKeyDown = (event) => {
// Check if user is typing in an input
const activeElement = document.activeElement;
const isInput =
activeElement.tagName === 'INPUT' ||
activeElement.tagName === 'TEXTAREA' ||
activeElement.isContentEditable;
if (isInput) return;
// Match shortcut
const shortcut = shortcuts.find(s => {
return (
event.key.toLowerCase() === s.key.toLowerCase() &&
event.ctrlKey === !!s.ctrl &&
event.altKey === !!s.alt &&
event.shiftKey === !!s.shift
);
});
if (shortcut) {
event.preventDefault();
shortcut.action();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [shortcuts]);
}
// Usage
function App() {
const navigate = useNavigate();
const [searchOpen, setSearchOpen] = useState(false);
useKeyboardShortcuts([
{
key: '/',
action: () => setSearchOpen(true),
description: 'Open search',
},
{
key: 'h',
action: () => navigate('/'),
description: 'Go to homepage',
},
{
key: 'p',
ctrl: true,
action: () => navigate('/profile'),
description: 'Go to profile',
},
{
key: '?',
shift: true,
action: () => setHelpOpen(true),
description: 'Show keyboard shortcuts',
},
]);
return <div>{/* App content */}</div>;
}
// Shortcut help modal
function KeyboardShortcutsHelp({ shortcuts }) {
return (
<div role="dialog" aria-label="Keyboard shortcuts">
<h2>Keyboard Shortcuts</h2>
<dl>
{shortcuts.map(shortcut => (
<div key={shortcut.key}>
<dt>
<kbd>
{shortcut.ctrl && 'Ctrl + '}
{shortcut.alt && 'Alt + '}
{shortcut.shift && 'Shift + '}
{shortcut.key.toUpperCase()}
</kbd>
</dt>
<dd>{shortcut.description}</dd>
</div>
))}
</dl>
</div>
);
}
Roving TabIndex for Lists and Grids
For components like toolbars or grids, use roving tabindex to manage focus with arrow keys:
function Toolbar({ items }) {
const [focusedIndex, setFocusedIndex] = useState(0);
const itemRefs = useRef([]);
const handleKeyDown = (event, index) => {
let newIndex = index;
switch (event.key) {
case 'ArrowRight':
newIndex = (index + 1) % items.length;
break;
case 'ArrowLeft':
newIndex = (index - 1 + items.length) % items.length;
break;
case 'Home':
newIndex = 0;
break;
case 'End':
newIndex = items.length - 1;
break;
default:
return;
}
event.preventDefault();
setFocusedIndex(newIndex);
itemRefs.current[newIndex]?.focus();
};
return (
<div role="toolbar" aria-label="Text formatting">
{items.map((item, index) => (
<button
key={item.id}
ref={el => itemRefs.current[index] = el}
tabIndex={focusedIndex === index ? 0 : -1}
onClick={item.onClick}
onKeyDown={e => handleKeyDown(e, index)}
aria-label={item.label}
>
{item.icon}
</button>
))}
</div>
);
}
// Usage
const toolbarItems = [
{ id: 'bold', label: 'Bold', icon: <BoldIcon />, onClick: () => format('bold') },
{ id: 'italic', label: 'Italic', icon: <ItalicIcon />, onClick: () => format('italic') },
{ id: 'underline', label: 'Underline', icon: <UnderlineIcon />, onClick: () => format('underline') },
];
Focus Traps for Modals
When a modal opens, trap focus inside it to prevent tabbing to background content:
import { useEffect, useRef } from 'react';
function useFocusTrap(isActive) {
const containerRef = useRef(null);
useEffect(() => {
if (!isActive || !containerRef.current) return;
const container = containerRef.current;
const focusableElements = container.querySelectorAll(
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
// Focus first element
firstElement?.focus();
const handleKeyDown = (event) => {
if (event.key !== 'Tab') return;
if (event.shiftKey) {
// Shift + Tab
if (document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
}
} else {
// Tab
if (document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
}
};
container.addEventListener('keydown', handleKeyDown);
return () => container.removeEventListener('keydown', handleKeyDown);
}, [isActive]);
return containerRef;
}
// Usage
function Modal({ isOpen, onClose, children }) {
const modalRef = useFocusTrap(isOpen);
const previousFocusRef = useRef(null);
useEffect(() => {
if (isOpen) {
previousFocusRef.current = document.activeElement;
} else {
previousFocusRef.current?.focus();
}
}, [isOpen]);
if (!isOpen) return null;
return (
<div className="modal-overlay">
<div ref={modalRef} role="dialog" aria-modal="true">
{children}
<button onClick={onClose}>Close</button>
</div>
</div>
);
}
Arrow Key Navigation for Custom Components
function ListBox({ options, value, onChange }) {
const [focusedIndex, setFocusedIndex] = useState(
options.findIndex(opt => opt.value === value)
);
const optionRefs = useRef([]);
const handleKeyDown = (event) => {
let newIndex = focusedIndex;
switch (event.key) {
case 'ArrowDown':
newIndex = Math.min(focusedIndex + 1, options.length - 1);
break;
case 'ArrowUp':
newIndex = Math.max(focusedIndex - 1, 0);
break;
case 'Home':
newIndex = 0;
break;
case 'End':
newIndex = options.length - 1;
break;
case 'Enter':
case ' ':
onChange(options[focusedIndex].value);
return;
default:
return;
}
event.preventDefault();
setFocusedIndex(newIndex);
optionRefs.current[newIndex]?.focus();
};
return (
<ul role="listbox" aria-label="Select option" onKeyDown={handleKeyDown}>
{options.map((option, index) => (
<li
key={option.value}
ref={el => optionRefs.current[index] = el}
role="option"
tabIndex={focusedIndex === index ? 0 : -1}
aria-selected={option.value === value}
onClick={() => onChange(option.value)}
>
{option.label}
</li>
))}
</ul>
);
}
Testing Keyboard Navigation
// Manual testing checklist:
// 1. Unplug your mouse
// 2. Tab through entire page
// 3. Try all keyboard shortcuts
// 4. Navigate through modals
// 5. Test form submissions
// 6. Verify focus indicators are visible
// Automated testing with Testing Library
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
test('keyboard navigation works', async () => {
const user = userEvent.setup();
render(<App />);
// Tab to button
await user.tab();
expect(screen.getByRole('button', { name: 'Submit' })).toHaveFocus();
// Press Enter
await user.keyboard('{Enter}');
expect(mockSubmit).toHaveBeenCalled();
});
test('skip link works', async () => {
const user = userEvent.setup();
render(<App />);
// Tab to skip link
await user.tab();
const skipLink = screen.getByText('Skip to main content');
expect(skipLink).toHaveFocus();
// Activate skip link
await user.keyboard('{Enter}');
expect(screen.getByRole('main')).toHaveFocus();
});
Common Keyboard Navigation Mistakes
❌ Removing Focus Outlines
/* Bad */
*:focus {
outline: none;
}
/* Good */
*:focus-visible {
outline: 3px solid blue;
outline-offset: 2px;
}
❌ Not Managing Focus on Route Changes
// Bad - Focus stays on old link
<Link to="/about">About</Link>
// Good - Focus moves to new page
useEffect(() => {
mainRef.current?.focus();
}, [location]);
❌ Forgetting Skip Links
// Bad - Users must tab through entire nav
<nav>{/* 20+ links */}</nav>
<main>{/* Content */}</main>
// Good - Skip link lets users jump to content
<a href="#main">Skip to content</a>
<nav>{/* Links */}</nav>
<main id="main">{/* Content */}</main>
Best Practices Checklist
- ✅ All interactive elements are keyboard accessible
- ✅ Focus indicators are clearly visible
- ✅ Tab order follows visual order
- ✅ Skip links are present and functional
- ✅ Focus moves to main content on route changes
- ✅ Modals trap focus inside when open
- ✅ Arrow keys work for lists and toolbars
- ✅ Keyboard shortcuts don't conflict with browser/screen reader shortcuts
- ✅ Escape key closes modals and menus
- ✅ Test navigation with mouse unplugged
Essential Keyboard Shortcuts Reference
| Key | Action | Context |
|---|---|---|
| Tab | Move focus forward | Global |
| Shift + Tab | Move focus backward | Global |
| Enter | Activate link/button | Interactive elements |
| Space | Activate button/checkbox | Buttons, checkboxes |
| Escape | Close/cancel | Modals, dropdowns |
| Arrow keys | Navigate within component | Tabs, menus, lists |
| Home | Go to first item | Lists, toolbars |
| End | Go to last item | Lists, toolbars |
Resources for Further Learning
- ARIA Authoring Practices - Keyboard patterns for components
- WebAIM - Keyboard accessibility guidelines
- Pa11y - Automated accessibility testing
- axe DevTools - Browser extension for testing
- React Focus Trap - Library for focus management
Key Takeaways
- Keyboard navigation is essential for accessibility compliance
- SPAs require manual focus management on route changes
- Skip links save users time navigating repetitive content
- Focus traps prevent users from leaving modals accidentally
- Roving tabindex improves navigation in toolbars and lists
- Never remove focus outlines without providing alternatives
- Test with your mouse unplugged to catch issues
Keyboard navigation might seem complex, but it follows predictable patterns. Start with the basics—visible focus indicators, skip links, and focus management on route changes. Then add advanced features like keyboard shortcuts and roving tabindex. Your keyboard users will thank you!
💡 Pro tip: Install the "Tab Order" Chrome extension to visualize tab order on your page. It's incredibly helpful for debugging focus issues!