Warning: Positive tabindex values (1+) override natural document flow and become a maintenance
nightmare. Never use them unless absolutely necessary for legacy code.
Note: Never use outline: none without providing an alternative focus indicator.
This breaks keyboard navigation for millions of users.
3. Skip Links 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
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 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
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