Focus Management
16 min read
Mastering Focus Management
Focus management is crucial for keyboard and assistive technology users. Poor focus management can make websites confusing, frustrating, or completely unusable. This comprehensive guide covers everything you need to know about managing focus effectively in web applications.
What Is Focus?
Focus indicates which element on the page is currently ready to receive user input. When an element has focus:
- Keyboard events are directed to it
- Screen readers announce it
- Visual focus indicators are displayed
Naturally Focusable Elements
These elements receive focus by default:
html
<!-- Links with href -->
<a href="/about">About</a>
<!-- Buttons -->
<button>Click me</button>
<!-- Form inputs -->
<input type="text" />
<textarea></textarea>
<select></select>
<!-- Interactive controls -->
<details><summary>Toggle</summary>Content</details>Making Elements Focusable
Use tabindex to control focusability:
html
<!-- tabindex="0": Add to focus order -->
<div tabindex="0" role="button" onclick="handleClick()">
Custom button
</div>
<!-- tabindex="-1": Focusable via JavaScript only -->
<div tabindex="-1" id="modal-content">
Modal content that receives focus programmatically
</div>
<!-- NEVER use positive tabindex -->
<button tabindex="5">Bad practice!</button>Focus Order Principles
The focus order should:
- Follow visual order: Left-to-right, top-to-bottom (for LTR languages)
- Be logical: Related items grouped together
- Be consistent: Same order on repeated visits
- Match DOM order: CSS positioning shouldn't break flow
CSS That Breaks Focus Order
css
/* Problematic: Visual order doesn't match DOM */
.container {
display: flex;
flex-direction: row-reverse;
}
/* Visual: C B A
Focus: A B C */Solution: Reorder the HTML, not just the CSS.
Focus Trapping
For modal dialogs and similar components, trap focus inside:
javascript
function trapFocus(element) {
const focusableElements = element.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
element.addEventListener('keydown', (e) => {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
// Shift + Tab
if (document.activeElement === firstElement) {
lastElement.focus();
e.preventDefault();
}
} else {
// Tab
if (document.activeElement === lastElement) {
firstElement.focus();
e.preventDefault();
}
}
});
}Focus Management in React
jsx
import { useRef, useEffect } from 'react';
function Modal({ isOpen, onClose, children }) {
const modalRef = useRef(null);
const previousActiveElement = useRef(null);
useEffect(() => {
if (isOpen) {
// Store current focus
previousActiveElement.current = document.activeElement;
// Focus the modal
modalRef.current?.focus();
}
return () => {
// Restore focus when modal closes
previousActiveElement.current?.focus();
};
}, [isOpen]);
if (!isOpen) return null;
return (
<div
ref={modalRef}
tabIndex={-1}
role="dialog"
aria-modal="true"
onKeyDown={(e) => e.key === 'Escape' && onClose()}
>
{children}
<button onClick={onClose}>Close</button>
</div>
);
}SPA Route Changes
Single-page applications need special focus management:
jsx
// Next.js example
import { usePathname } from 'next/navigation';
import { useEffect, useRef } from 'react';
function useFocusOnRouteChange() {
const pathname = usePathname();
const mainRef = useRef(null);
useEffect(() => {
// Focus the main content area on route change
mainRef.current?.focus();
}, [pathname]);
return mainRef;
}
function Layout({ children }) {
const mainRef = useFocusOnRouteChange();
return (
<>
<nav>...</nav>
<main ref={mainRef} tabIndex={-1}>
{children}
</main>
</>
);
}Common Focus Management Patterns
Skip Links
html
<body>
<a href="#main-content" class="skip-link">
Skip to main content
</a>
<header>...</header>
<main id="main-content" tabindex="-1">
...
</main>
</body>
<style>
.skip-link {
position: absolute;
top: -40px;
left: 0;
padding: 8px;
z-index: 100;
}
.skip-link:focus {
top: 0;
}
</style>Accordion Focus
jsx
function Accordion({ items }) {
const [openIndex, setOpenIndex] = useState(null);
return (
<div role="region" aria-label="Accordion">
{items.map((item, index) => (
<div key={index}>
<button
aria-expanded={openIndex === index}
aria-controls={`panel-${index}`}
onClick={() => setOpenIndex(openIndex === index ? null : index)}
>
{item.title}
</button>
<div
id={`panel-${index}`}
role="region"
hidden={openIndex !== index}
>
{item.content}
</div>
</div>
))}
</div>
);
}Testing Focus Management
- Tab Navigation: Can you reach all interactive elements?
- Logical Order: Does tab order make sense?
- Focus Visibility: Can you always see where focus is?
- Modal Behavior: Is focus trapped correctly?
- Focus Restoration: Does focus return after dialogs close?
- Route Changes: Is focus handled in SPAs?
Was this article helpful?