Keyboard Navigation and Focus Management

1. Tab Order and Tabindex Usage

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 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.
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
<!-- 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 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

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-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