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
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.
Example: Comprehensive keyboard handler for custom button
// Custom button with keyboard supportconst 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 behaviorcustomButton.addEventListener('keyup', (e) => { if (e.key === ' ') { e.preventDefault(); }});// Arrow key navigation for menuconst 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(); } }}// Usageconst modal = document.querySelector('[role="dialog"]');const trap = new FocusTrap(modal);// When opening modalfunction openModal() { modal.style.display = 'block'; modal.setAttribute('aria-hidden', 'false'); trap.activate();}// When closing modalfunction 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 classclass 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 scrollingconst heading = document.querySelector('h1');FocusManager.moveFocusTo(heading, true);// Navigate to next focusable elementdocument.addEventListener('keydown', (e) => { if (e.ctrlKey && e.key === 'ArrowDown') { e.preventDefault(); FocusManager.focusNext(); }});// Make background inert when modal opensfunction openModal(modal) { document.body.setAttribute('inert', ''); modal.removeAttribute('inert'); modal.querySelector('button').focus();}// Track focus changesdocument.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-visible instead
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 inert attribute to disable entire sections during modal interactions
Test with keyboard only (unplug mouse) to verify all functionality is accessible