Keyboard Navigation and Focus Management
1. Tab Order and Tabindex (tabindex="0", "-1")
| tabindex Value | Behavior | Use Case | Support |
|---|---|---|---|
| tabindex="0" | Element enters natural tab order | Make custom controls focusable (buttons, widgets) | All browsers |
| tabindex="-1" | Focusable via JS only, removed from tab order | Programmatic focus (skip links, dialogs) | All browsers |
| tabindex="1+" AVOID | Sets explicit tab order (anti-pattern) | None - breaks natural flow, hard to maintain | All browsers |
| No tabindex | Native focusable elements (buttons, links, inputs) | Default for interactive HTML elements | All browsers |
Example: Making a custom button focusable
<!-- Good: Custom widget with tabindex="0" -->
<div role="button" tabindex="0" onclick="handleClick()">
Click Me
</div>
<!-- Good: Programmatic focus target -->
<div id="main-content" tabindex="-1">
Main content starts here
</div>
<!-- Bad: Positive tabindex disrupts natural order -->
<button tabindex="3">Third</button>
<button tabindex="1">First</button>
<button tabindex="2">Second</button>
Warning: Positive tabindex values (1+) override natural document flow and become a maintenance
nightmare. Never use them unless absolutely necessary for legacy code.
| Natural Tab Order | Elements Included | Notes |
|---|---|---|
| Interactive Elements | <a>, <button>, <input>,
<select>, <textarea>
|
Automatically focusable in document order |
| Editable Content | contenteditable="true" |
Becomes focusable and editable |
| Media Controls | <video controls>, <audio controls> |
Built-in controls are keyboard accessible |
| Summary/Details | <summary> inside <details> |
Toggle button is automatically focusable |
2. Focus Indicators (:focus, :focus-visible) and Styling
| CSS Pseudo-class | Triggers | Use Case | Browser Support |
|---|---|---|---|
| :focus | Any focus (keyboard or mouse) | Basic focus styling (legacy) | All browsers |
| :focus-visible NEW | Keyboard focus only | Show indicators only for keyboard users | Modern browsers |
| :focus-within | Element or descendant has focus | Style parent when child is focused | Modern browsers |
| :has(:focus) | Contains focused element | Advanced parent styling (modern) | Chrome 105+, Safari 15.4+ |
Example: Modern focus indicator patterns
/* Basic focus indicator - always visible */
button:focus {
outline: 2px solid #0066cc;
outline-offset: 2px;
}
/* Keyboard-only focus indicator (recommended) */
button:focus-visible {
outline: 3px solid #0066cc;
outline-offset: 3px;
box-shadow: 0 0 0 4px rgba(0, 102, 204, 0.2);
}
/* Remove mouse focus indicator */
button:focus:not(:focus-visible) {
outline: none;
}
/* Style parent form when any input is focused */
.form-group:focus-within {
background: #f0f8ff;
border-color: #0066cc;
}
/* High contrast mode support */
@media (prefers-contrast: high) {
button:focus-visible {
outline: 4px solid currentColor;
outline-offset: 4px;
}
}
| Focus Indicator Best Practice | Minimum Requirement | Recommended |
|---|---|---|
| Contrast Ratio | 3:1 against background (WCAG 2.2) | 4.5:1 for better visibility |
| Thickness | 2px minimum | 3px for clearer visibility |
| Offset | 0-2px from element | 2-4px for better separation |
| Shape | Follows element shape or rectangle | Add rounded corners for modern look |
| Animation | Respect prefers-reduced-motion |
Subtle fade-in (150-200ms max) |
Note: Never use
outline: none without providing an alternative focus indicator.
This breaks keyboard navigation for millions of users.
3. Skip Links (Skip to Main Content) Implementation
| Skip Link Type | Target | Usage | Position |
|---|---|---|---|
| Skip to Main | #main-content |
Bypass navigation/header | First focusable element on page |
| Skip to Navigation | #primary-nav |
Quick access to menu | Second skip link (optional) |
| Skip to Footer | #footer |
Jump to contact/legal info | Third skip link (rare) |
| Skip Repeating Content | Section-specific | Bypass sidebars, ads, related content | Before repeated blocks |
Example: Complete skip link implementation
<!-- HTML Structure -->
<a href="#main-content" class="skip-link">Skip to main content</a>
<header>...navigation...</header>
<main id="main-content" tabindex="-1">
<h1>Page Title</h1>
...
</main>
<!-- CSS: Show on focus -->
<style>
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: #0066cc;
color: #fff;
padding: 8px 16px;
text-decoration: none;
font-weight: 600;
z-index: 9999;
transition: top 0.2s;
}
.skip-link:focus {
top: 0;
outline: 3px solid #fff;
outline-offset: 2px;
}
</style>
<!-- JavaScript: Ensure focus works in all browsers -->
<script>
document.querySelectorAll('a[href^="#"]').forEach(link => {
link.addEventListener('click', e => {
const target = document.querySelector(link.getAttribute('href'));
if (target) {
target.focus();
// Fallback for elements without tabindex
if (document.activeElement !== target) {
target.setAttribute('tabindex', '-1');
target.focus();
}
}
});
});
</script>
Warning: Skip links must be the first focusable element on the
page. Don't hide them with
display: none or visibility: hidden - use off-screen
positioning instead.
4. Keyboard Event Handling (keydown, keyup, keypress)
| Key | Event Code | Common Usage | Widget Pattern |
|---|---|---|---|
| Enter | event.key === 'Enter' |
Activate buttons, submit forms | Buttons, links, dialogs |
| Space | event.key === ' ' |
Activate buttons, toggle checkboxes | Buttons, checkboxes, switches |
| Escape | event.key === 'Escape' |
Close dialogs, cancel operations | Modals, dropdowns, tooltips |
| Arrow Keys | ArrowUp/Down/Left/Right |
Navigate lists, adjust values | Menus, tabs, sliders, grids |
| Tab | event.key === 'Tab' |
Move focus forward | General navigation |
| Shift+Tab | event.shiftKey && event.key === 'Tab' |
Move focus backward | General navigation |
| Home | event.key === 'Home' |
Jump to first item | Lists, grids, sliders |
| End | event.key === 'End' |
Jump to last item | Lists, grids, sliders |
| Page Up/Down | PageUp/PageDown |
Scroll by page, jump items | Long lists, calendars |
Example: Comprehensive keyboard handler for custom button
// Custom button with keyboard support
const customButton = document.querySelector('[role="button"]');
customButton.addEventListener('keydown', (e) => {
// Activate on Enter or Space
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); // Prevent page scroll on Space
customButton.click();
}
});
// Prevent default space scroll behavior
customButton.addEventListener('keyup', (e) => {
if (e.key === ' ') {
e.preventDefault();
}
});
// Arrow key navigation for menu
const menuItems = document.querySelectorAll('[role="menuitem"]');
let currentIndex = 0;
document.addEventListener('keydown', (e) => {
if (!menuItems.length) return;
switch(e.key) {
case 'ArrowDown':
e.preventDefault();
currentIndex = (currentIndex + 1) % menuItems.length;
menuItems[currentIndex].focus();
break;
case 'ArrowUp':
e.preventDefault();
currentIndex = (currentIndex - 1 + menuItems.length) % menuItems.length;
menuItems[currentIndex].focus();
break;
case 'Home':
e.preventDefault();
currentIndex = 0;
menuItems[0].focus();
break;
case 'End':
e.preventDefault();
currentIndex = menuItems.length - 1;
menuItems[currentIndex].focus();
break;
case 'Escape':
closeMenu();
break;
}
});
| Widget Pattern | Required Keys | Optional Keys | ARIA Pattern Link |
|---|---|---|---|
| Button | Enter, Space | - | Native behavior preferred |
| Tabs | Left/Right Arrow, Home, End | Delete (closable tabs) | Automatic activation recommended |
| Menu | Up/Down Arrow, Escape, Enter | Home, End, type-ahead | First item focused on open |
| Dialog | Escape (close) | - | Focus trap required |
| Slider | Left/Right or Up/Down Arrow | Home, End, Page Up/Down | Arrow keys change value |
| Grid | All arrow keys, Home, End | Ctrl+Home, Ctrl+End, Page Up/Down | Cell-level or row-level focus |
Note: Always use
event.key instead of deprecated event.keyCode. Use
event.preventDefault() to prevent default browser behavior when implementing custom key handling.
5. Focus Trap Patterns (Modal Dialogs)
| Focus Trap Type | Use Case | Implementation | Exit Method |
|---|---|---|---|
| Modal Dialog | Blocking user action required | Trap focus within dialog boundary | Escape key or explicit close button |
| Navigation Menu | Mega menu or sidebar | Trap while menu is open | Escape, outside click, or navigate |
| Inline Edit Mode | Table cell or content editing | Trap in edit controls | Save/Cancel or Enter/Escape |
| Multi-step Wizard | Form completion flow | Trap within current step | Next/Previous or Cancel button |
Example: Complete focus trap implementation for modal
class FocusTrap {
constructor(element) {
this.element = element;
this.focusableSelectors = [
'a[href]',
'button:not([disabled])',
'textarea:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'[tabindex]:not([tabindex="-1"])'
].join(', ');
this.previousFocus = null;
}
activate() {
// Store currently focused element
this.previousFocus = document.activeElement;
// Get all focusable elements
const focusable = Array.from(
this.element.querySelectorAll(this.focusableSelectors)
);
if (!focusable.length) return;
this.firstFocusable = focusable[0];
this.lastFocusable = focusable[focusable.length - 1];
// Focus first element
this.firstFocusable.focus();
// Add event listeners
this.element.addEventListener('keydown', this.handleKeydown);
}
handleKeydown = (e) => {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
// Shift+Tab: wrap to last element
if (document.activeElement === this.firstFocusable) {
e.preventDefault();
this.lastFocusable.focus();
}
} else {
// Tab: wrap to first element
if (document.activeElement === this.lastFocusable) {
e.preventDefault();
this.firstFocusable.focus();
}
}
}
deactivate() {
this.element.removeEventListener('keydown', this.handleKeydown);
// Restore focus to previous element
if (this.previousFocus && this.previousFocus.focus) {
this.previousFocus.focus();
}
}
}
// Usage
const modal = document.querySelector('[role="dialog"]');
const trap = new FocusTrap(modal);
// When opening modal
function openModal() {
modal.style.display = 'block';
modal.setAttribute('aria-hidden', 'false');
trap.activate();
}
// When closing modal
function closeModal() {
trap.deactivate();
modal.style.display = 'none';
modal.setAttribute('aria-hidden', 'true');
}
Warning: Focus traps must always include an accessible way to
exit (Escape key minimum). Trapping users without an exit is a WCAG violation and creates a terrible UX.
| Focus Trap Best Practice | Requirement | Reason |
|---|---|---|
| Store Previous Focus | Save document.activeElement before trap |
Restore focus when trap deactivates |
| Focus First Element | Move focus to first focusable on open | Clear starting point for keyboard users |
| Handle Tab Wrap | Tab from last → first, Shift+Tab from first → last | Prevent focus escaping trap boundary |
| Update Dynamically | Recalculate focusable elements if content changes | Ensure trap includes new/removed elements |
| Escape Key Exit | Always listen for Escape to close | Standard pattern users expect |
| Inert Background | Use inert attribute on non-trap elements |
Prevent interaction with background content |
6. Custom Focus Management APIs
| API/Method | Purpose | Syntax | Browser Support |
|---|---|---|---|
| element.focus() | Move focus to element | element.focus({ preventScroll: true }) |
All browsers |
| element.blur() | Remove focus from element | element.blur() |
All browsers |
| document.activeElement | Get currently focused element | const focused = document.activeElement |
All browsers |
| element.matches(':focus-within') | Check if element or descendant has focus | if (parent.matches(':focus-within')) {...} |
Modern browsers |
| inert attribute NEW | Remove element and descendants from tab order | <div inert>...</div> |
Chrome 102+, Firefox 112+ |
| focusin/focusout events | Listen for focus changes (bubbling) | element.addEventListener('focusin', fn) |
All browsers |
| focus/blur events | Listen for focus changes (non-bubbling) | element.addEventListener('focus', fn, true) |
All browsers |
Example: Advanced focus management patterns
// Focus management utility class
class FocusManager {
// Move focus with optional scroll prevention
static moveFocusTo(element, preventScroll = false) {
if (!element) return;
element.focus({ preventScroll });
}
// Focus next/previous focusable element
static focusNext() {
const focusable = this.getFocusableElements();
const currentIndex = focusable.indexOf(document.activeElement);
const nextIndex = (currentIndex + 1) % focusable.length;
focusable[nextIndex].focus();
}
static focusPrevious() {
const focusable = this.getFocusableElements();
const currentIndex = focusable.indexOf(document.activeElement);
const prevIndex = (currentIndex - 1 + focusable.length) % focusable.length;
focusable[prevIndex].focus();
}
// Get all focusable elements in document
static getFocusableElements() {
const selector = [
'a[href]',
'button:not([disabled])',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
'[contenteditable="true"]'
].join(',');
return Array.from(document.querySelectorAll(selector))
.filter(el => {
// Filter out hidden and inert elements
return el.offsetParent !== null && !el.hasAttribute('inert');
});
}
// Check if element is currently focused
static isFocused(element) {
return document.activeElement === element;
}
// Check if element or child has focus
static hasFocusWithin(element) {
return element.matches(':focus-within');
}
// Restore focus after async operation
static async withFocusRestore(callback) {
const previousFocus = document.activeElement;
await callback();
if (previousFocus && previousFocus.focus) {
previousFocus.focus();
}
}
}
// Usage examples
// Focus first heading without scrolling
const heading = document.querySelector('h1');
FocusManager.moveFocusTo(heading, true);
// Navigate to next focusable element
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'ArrowDown') {
e.preventDefault();
FocusManager.focusNext();
}
});
// Make background inert when modal opens
function openModal(modal) {
document.body.setAttribute('inert', '');
modal.removeAttribute('inert');
modal.querySelector('button').focus();
}
// Track focus changes
document.addEventListener('focusin', (e) => {
console.log('Focus moved to:', e.target);
// Announce to screen reader if needed
announceToScreenReader(`Focused on ${e.target.getAttribute('aria-label')}`);
});
| SPA Focus Pattern | Scenario | Implementation |
|---|---|---|
| Route Change | User navigates to new page | Focus page heading or skip link target |
| Content Update | Async data loads in current view | Move focus to new content heading or first interactive element |
| Error State | Form validation fails | Focus first invalid field and announce error |
| Success Notification | Action completes successfully | Focus notification or next logical element |
| Loading State | Content is loading | Keep focus on trigger element or loading indicator |
| Modal Close | Dialog dismissed | Return focus to element that opened modal |
Note: The
inert attribute is a powerful tool for managing focus in complex UIs. It
removes entire subtrees from tab order and screen reader navigation, making it ideal for modal backgrounds and
hidden content.
Keyboard Navigation Quick Reference
- Use
tabindex="0"for custom interactive elements,"-1"for programmatic focus only - Never remove focus indicators without providing an alternative - use
:focus-visibleinstead - Skip links must be the first focusable element and visible on focus
- Implement keyboard handlers for all custom widgets following ARIA authoring practices
- Focus traps must have an escape mechanism (minimum: Escape key)
- Restore focus to previous element when dismissing modals or completing flows
- Use
inertattribute to disable entire sections during modal interactions - Test with keyboard only (unplug mouse) to verify all functionality is accessible