PatternsAccessibility
Focus Management in SPAs
Handle focus correctly during route changes and dynamic updates
In traditional multi-page apps, focus resets automatically on navigation. In SPAs, you must manage focus manually to maintain accessibility for keyboard and screen reader users.
The Problem
// ❌ BAD: No focus management
function App() {
return (
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
</Routes>
);
}
// User clicks link, route changes
// Focus stays on the link
// Screen reader doesn't announce new page
// Keyboard users confusedThe Solution
// ✅ GOOD: Focus management
function App() {
const location = useLocation();
const mainRef = useRef<HTMLElement>(null);
useEffect(() => {
// Move focus to main content on route change
mainRef.current?.focus();
// Scroll to top
window.scrollTo(0, 0);
}, [location]);
return (
<main ref={mainRef} tabIndex={-1} style={{ outline: 'none' }}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
</Routes>
</main>
);
}Skip Links
// Allow users to skip navigation
export function Layout({ children }: Props) {
return (
<>
<a href="#main-content" className="skip-link">
Skip to main content
</a>
<header>
<nav>...</nav>
</header>
<main id="main-content" tabIndex={-1}>
{children}
</main>
</>
);
}
// CSS
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: #000;
color: #fff;
padding: 8px;
z-index: 100;
}
.skip-link:focus {
top: 0;
}Focus on Route Change
// hooks/useFocusOnRouteChange.ts
export function useFocusOnRouteChange(ref: RefObject<HTMLElement>) {
const location = useLocation();
const prevLocation = useRef(location);
useEffect(() => {
// Only focus if route actually changed
if (location.pathname !== prevLocation.current.pathname) {
ref.current?.focus();
prevLocation.current = location;
}
}, [location, ref]);
}
// Usage
export function Page() {
const mainRef = useRef<HTMLElement>(null);
useFocusOnRouteChange(mainRef);
return (
<main ref={mainRef} tabIndex={-1}>
<h1>Page Content</h1>
</main>
);
}Modal Dialogs - Focus Trap
// components/Modal.tsx
export function Modal({ isOpen, onClose, children }: Props) {
const modalRef = useRef<HTMLDivElement>(null);
const previousFocus = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
// Save current focus
previousFocus.current = document.activeElement as HTMLElement;
// Focus first element in modal
const firstFocusable = modalRef.current?.querySelector<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
firstFocusable?.focus();
// Trap focus
const handleTab = (e: KeyboardEvent) => {
const focusableElements = modalRef.current?.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (!focusableElements) return;
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (e.key === 'Tab') {
if (e.shiftKey && document.activeElement === firstElement) {
e.preventDefault();
lastElement?.focus();
} else if (!e.shiftKey && document.activeElement === lastElement) {
e.preventDefault();
firstElement?.focus();
}
}
};
document.addEventListener('keydown', handleTab);
return () => {
document.removeEventListener('keydown', handleTab);
// Restore focus on close
previousFocus.current?.focus();
};
}
}, [isOpen]);
if (!isOpen) return null;
return (
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
<h2 id="modal-title">Modal Title</h2>
{children}
<button onClick={onClose}>Close</button>
</div>
);
}Focus Trap Hook
// hooks/useFocusTrap.ts
export function useFocusTrap(ref: RefObject<HTMLElement>, active: boolean) {
useEffect(() => {
if (!active || !ref.current) return;
const element = ref.current;
const focusableSelector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
// Get focusable elements
const focusableElements = Array.from(
element.querySelectorAll<HTMLElement>(focusableSelector)
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
// Focus first element
firstElement?.focus();
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
// Shift + Tab
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement?.focus();
}
} else {
// Tab
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement?.focus();
}
}
};
element.addEventListener('keydown', handleKeyDown);
return () => {
element.removeEventListener('keydown', handleKeyDown);
};
}, [ref, active]);
}
// Usage
export function Dialog({ isOpen, onClose }: Props) {
const dialogRef = useRef<HTMLDivElement>(null);
useFocusTrap(dialogRef, isOpen);
return (
<div ref={dialogRef} role="dialog">
{/* content */}
</div>
);
}Managing Focus After Actions
// Delete item and focus next item
export function ItemList() {
const [items, setItems] = useState([...]);
const itemRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
const deleteItem = (id: string, index: number) => {
setItems(prev => prev.filter(item => item.id !== id));
// Focus next item or previous
const nextIndex = Math.min(index, items.length - 2);
const nextId = items[nextIndex]?.id;
if (nextId) {
setTimeout(() => {
itemRefs.current.get(nextId)?.focus();
}, 0);
}
};
return (
<ul>
{items.map((item, index) => (
<li key={item.id}>
<span>{item.name}</span>
<button
ref={el => {
if (el) itemRefs.current.set(item.id, el);
}}
onClick={() => deleteItem(item.id, index)}
>
Delete
</button>
</li>
))}
</ul>
);
}Form Submission Errors
export function FormWithFocusManagement() {
const [errors, setErrors] = useState<Record<string, string>>({});
const errorRefs = useRef<Map<string, HTMLInputElement>>(new Map());
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Validate
const newErrors = validate(formData);
setErrors(newErrors);
// Focus first error
if (Object.keys(newErrors).length > 0) {
const firstErrorField = Object.keys(newErrors)[0];
const input = errorRefs.current.get(firstErrorField);
input?.focus();
input?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
ref={el => {
if (el) errorRefs.current.set('email', el);
}}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && (
<div id="email-error" role="alert">
{errors.email}
</div>
)}
</div>
<button type="submit">Submit</button>
</form>
);
}Dropdown Menu Focus
export function DropdownMenu() {
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (isOpen) {
// Focus first menu item
const firstItem = menuRef.current?.querySelector<HTMLButtonElement>('[role="menuitem"]');
firstItem?.focus();
}
}, [isOpen]);
const handleClose = () => {
setIsOpen(false);
// Return focus to button
buttonRef.current?.focus();
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
handleClose();
} else if (e.key === 'ArrowDown') {
e.preventDefault();
// Focus next item
const current = document.activeElement as HTMLElement;
const next = current.nextElementSibling as HTMLElement;
next?.focus();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
// Focus previous item
const current = document.activeElement as HTMLElement;
const prev = current.previousElementSibling as HTMLElement;
prev?.focus();
}
};
return (
<div>
<button
ref={buttonRef}
onClick={() => setIsOpen(!isOpen)}
aria-expanded={isOpen}
aria-haspopup="menu"
>
Menu
</button>
{isOpen && (
<div
ref={menuRef}
role="menu"
onKeyDown={handleKeyDown}
>
<button role="menuitem" onClick={handleClose}>Item 1</button>
<button role="menuitem" onClick={handleClose}>Item 2</button>
<button role="menuitem" onClick={handleClose}>Item 3</button>
</div>
)}
</div>
);
}Tabs with Keyboard Navigation
export function Tabs() {
const [activeTab, setActiveTab] = useState(0);
const tabRefs = useRef<HTMLButtonElement[]>([]);
const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
let newIndex = index;
if (e.key === 'ArrowRight') {
e.preventDefault();
newIndex = (index + 1) % tabRefs.current.length;
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
newIndex = (index - 1 + tabRefs.current.length) % tabRefs.current.length;
} else if (e.key === 'Home') {
e.preventDefault();
newIndex = 0;
} else if (e.key === 'End') {
e.preventDefault();
newIndex = tabRefs.current.length - 1;
}
if (newIndex !== index) {
setActiveTab(newIndex);
tabRefs.current[newIndex]?.focus();
}
};
return (
<div>
<div role="tablist">
{tabs.map((tab, index) => (
<button
key={tab.id}
ref={el => {
if (el) tabRefs.current[index] = el;
}}
role="tab"
aria-selected={activeTab === index}
aria-controls={`panel-${tab.id}`}
tabIndex={activeTab === index ? 0 : -1}
onClick={() => setActiveTab(index)}
onKeyDown={(e) => handleKeyDown(e, index)}
>
{tab.label}
</button>
))}
</div>
{tabs.map((tab, index) => (
<div
key={tab.id}
id={`panel-${tab.id}`}
role="tabpanel"
hidden={activeTab !== index}
tabIndex={0}
>
{tab.content}
</div>
))}
</div>
);
}Best Practices
- Focus main content on route change
- Restore focus after modal close
- Trap focus in modals/dialogs
- Focus first error on form submission
- Skip links for main content
- Keyboard navigation for custom widgets
- No focus outline removal (use
:focus-visible) - Test with keyboard only
- Announce route changes with live regions
- Scroll to focused element if needed
Common Pitfalls
❌ No focus management: Lost context
✅ Always manage focus on route changes
❌ Removing outlines: outline: none
✅ Use :focus-visible for styling
❌ No focus trap in modals: Can escape
✅ Trap focus, restore on close
❌ Not focusing errors: User confused
✅ Focus first error field
❌ Breaking tab order: tabindex > 0
✅ Use semantic HTML, tabindex="-1" only
Focus management is critical for SPA accessibility—always handle focus explicitly on dynamic changes!