Focus Fundamentals
- Focusindicates which element currently receives keyboard input
- Tabmoves focus forward;Shift+Tabmoves backward
- Onlyinteractive elementsreceive focus by default (links, buttons, form fields)
- Focus indicatormust be visible(WCAG 2.4.7 Focus Visible)
- Focus order must belogical and intuitive(WCAG 2.4.3 Focus Order)
Common Mistake
Never remove focus outlines without providing an alternative.outline: nonewithout a replacement makes your site unusable for keyboard users.
Visible Focus Indicators
Users must always be able to see where focus is. WCAG 2.4.13 (Level AAA) requires focus indicators with:
- At least 2px thickness
- 3:1 contrast against adjacent colors
- Minimum area of the element's perimeter
Default Browser Focus
/* Browsers provide default focus styles */
:focus {
outline: auto; /* Usually a blue ring */
}
The default is accessible—only override it if you provide somethingbetter.
Custom Focus Styles
/* Clear, visible custom focus */
:focus {
outline: 3px solid #005EA2;
outline-offset: 2px;
}
/* High contrast double ring */
:focus-visible {
outline: 2px solid #000000;
box-shadow: 0 0 0 4px #ffffff, 0 0 0 6px #000000;
}
/* For dark backgrounds */
.dark-section :focus {
outline: 3px solid #ffffff;
outline-offset: 2px;
}
Using :focus-visible
The:focus-visiblepseudo-class shows focus only for keyboard navigation, hiding it for mouse clicks:
/* Hide focus for mouse users, show for keyboard */
:focus {
outline: none;
}
:focus-visible {
outline: 3px solid #005EA2;
outline-offset: 2px;
}
Never Do This
/* Removes focus for everyone! */
:focus {
outline: none;
}
/* No :focus-visible fallback */
Do This Instead
:focus-visible {
outline: 3px solid #005EA2;
outline-offset: 2px;
}
Tab Order
Focus order should match the visual reading order (left-to-right, top-to-bottom in English).
Natural Tab Order
- Tab order followsDOM order, not visual order
- Use
tabindex="0"to add non-interactive elements to tab order - Use
tabindex="-1"for programmatic focus only (not in tab order) - Never use positive tabindex(tabindex="1", "2", etc.)—it creates maintenance nightmares
Tabindex Values
| Value | Behavior | Use Case |
|---|---|---|
tabindex="0" |
Added to natural tab order | Custom interactive widgets |
tabindex="-1" |
Focusable via JavaScript only | Modal containers, error messages |
tabindex="1+" |
Jumps ahead in order | Avoid—causes confusion |
Fixing Tab Order Issues
- Reorder your HTML to match the visual layout
- Use CSS Grid/Flexbox for visual positioning (doesn't affect tab order)
- Test by tabbing through your entire page
CSS Order Property
CSSorderin Flexbox/Grid changes visual order butnot tab order. This can create a confusing disconnect.
Managing Focus in Dynamic Content
Modal Dialogs
When opening a modal:
- Store the element that triggered it
- Move focus into the modal
- Trap focus inside (can't Tab out)
- On close, return focus to the trigger
// Native dialog (recommended)
const dialog = document.querySelector('dialog');
dialog.showModal(); // Handles focus automatically!
// Custom modal implementation
let previousFocus;
function openModal(modal) {
previousFocus = document.activeElement;
modal.hidden = false;
modal.setAttribute('aria-modal', 'true');
// Focus first focusable element or the modal itself
const focusable = modal.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
(focusable || modal).focus();
}
function closeModal(modal) {
modal.hidden = true;
modal.removeAttribute('aria-modal');
previousFocus?.focus(); // Return focus
}
Focus Trapping
Modal dialogs must trap focus—pressing Tab at the last element wraps to the first:
function trapFocus(container) {
const focusableSelector =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const focusable = [...container.querySelectorAll(focusableSelector)];
const firstFocusable = focusable[0];
const lastFocusable = focusable[focusable.length - 1];
container.addEventListener('keydown', (e) =>{
if (e.key !== 'Tab') return;
if (e.shiftKey) {
// Shift+Tab on first element go to last
if (document.activeElement === firstFocusable) {
e.preventDefault();
lastFocusable.focus();
}
} else {
// Tab on last element go to first
if (document.activeElement === lastFocusable) {
e.preventDefault();
firstFocusable.focus();
}
}
});
}
Single-Page App Navigation
When the route changes in a SPA, focus should move to the new content:
// After route change
function onRouteChange() {
const main = document.getElementById('main-content');
main.setAttribute('tabindex', '-1');
main.focus();
// Or announce via live region
announcer.textContent = `Navigated to ${document.title}`;
}
Accordion/Disclosure Widgets
// Focus stays on the trigger button
button.addEventListener('click', () =>{
const expanded = button.getAttribute('aria-expanded') === 'true';
button.setAttribute('aria-expanded', !expanded);
panel.hidden = expanded;
// Focus stays on button—don't move it
});
Deleted Items in Lists
When removing an item, move focus to a logical location:
function deleteItem(item) {
const nextItem = item.nextElementSibling || item.previousElementSibling;
const listHeading = item.closest('section')?.querySelector('h2');
item.remove();
// Focus next item, or the section heading if list is empty
if (nextItem) {
nextItem.querySelector('button')?.focus();
} else {
listHeading?.setAttribute('tabindex', '-1');
listHeading?.focus();
}
}
Testing Focus Management
- Unplug your mouse(or move it aside)
- Tab through the entire page—can you reach everything interactive?
- Watch the focus indicator—is it always visible?
- Test modals—does focus trap? Does it return on close?
- Test route changes—does focus move appropriately?
- Test with a screen reader—NVDA, VoiceOver, or JAWS
Browser DevTools
Chrome DevTools can track focus:
- Open DevTools (F12)
- Go to Console
- Run:
document.addEventListener('focusin', e =>console.log(e.target)) - Tab through the page to see focus logged