SalakCode SalakCode
Accessibility

Focus Management

Control keyboard navigation flow

Intermediate
a11y focus keyboard modals

Definition

Focus management controls which element receives keyboard input. Proper focus handling is essential for keyboard users and screen reader users, especially when opening modals, navigating single-page applications, or handling dynamic content updates.

Focus Basics

Focusable Elements

<!-- Naturally focusable -->
<a href="...">Link</a>
<button>Button</button>
<input />
<select>...</select>
<textarea>...</textarea>
<iframe>...</iframe>

<!-- Make focusable -->
<div tabindex="0">Focusable div</div>

Tabindex Values

<!-- tabindex="0" - Focusable in natural tab order -->
<div tabindex="0">Custom widget</div>

<!-- tabindex="-1" - Programmatically focusable only -->
<div tabindex="-1" id="error-summary">Errors... (focused on validation)</div>

<!-- tabindex=">0" - Avoid: Creates custom tab order -->
<div tabindex="5">Don't do this</div>

Focus Trap

function FocusTrap(container) {
  const focusableElements = container.querySelectorAll(
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
  );
  
  const firstElement = focusableElements[0];
  const lastElement = focusableElements[focusableElements.length - 1];
  
  container.addEventListener('keydown', (e) => {
    if (e.key !== 'Tab') return;
    
    if (e.shiftKey && document.activeElement === firstElement) {
      e.preventDefault();
      lastElement.focus();
    } else if (!e.shiftKey && document.activeElement === lastElement) {
      e.preventDefault();
      firstElement.focus();
    }
  });
}

// Usage
const modal = document.getElementById('modal');
FocusTrap(modal);
class Modal {
  constructor(element) {
    this.element = element;
    this.previousActiveElement = null;
  }
  
  open() {
    // Save current focus
    this.previousActiveElement = document.activeElement;
    
    // Show modal
    this.element.hidden = false;
    
    // Focus first focusable element or modal itself
    const focusable = this.element.querySelector(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    
    if (focusable) {
      focusable.focus();
    } else {
      this.element.tabIndex = -1;
      this.element.focus();
    }
    
    // Trap focus
    this.trapFocus();
  }
  
  close() {
    // Hide modal
    this.element.hidden = true;
    
    // Restore focus
    if (this.previousActiveElement) {
      this.previousActiveElement.focus();
    }
  }
  
  trapFocus() {
    this.element.addEventListener('keydown', (e) => {
      if (e.key === 'Escape') {
        this.close();
      }
    });
  }
}

Focus Management Patterns

<!-- Hidden skip link visible on focus -->
<a href="#main-content" class="skip-link">
  Skip to main content
</a>

<main id="main-content" tabindex="-1">
  <!-- Content here -->
</main>

<style>
.skip-link {
  position: absolute;
  top: -40px;
  left: 0;
  background: #000;
  color: #fff;
  padding: 8px;
  text-decoration: none;
}

.skip-link:focus {
  top: 0;
}
</style>

Dynamic Content

// Move focus to new content
function loadNewContent() {
  const newSection = document.createElement('section');
  newSection.innerHTML = '<h2 tabindex="-1">New Content</h2>...';
  newSection.id = 'new-section';
  
  document.body.appendChild(newSection);
  
  // Focus the heading
  newSection.querySelector('h2').focus();
  
  // Announce to screen readers
  announce('New content loaded');
}

function announce(message) {
  const announcer = document.createElement('div');
  announcer.setAttribute('aria-live', 'polite');
  announcer.setAttribute('aria-atomic', 'true');
  announcer.className = 'sr-only';
  document.body.appendChild(announcer);
  
  setTimeout(() => {
    announcer.textContent = message;
  }, 100);
  
  setTimeout(() => {
    document.body.removeChild(announcer);
  }, 1000);
}

Form Validation

function validateForm(form) {
  const errors = [];
  
  // Validate fields...
  
  if (errors.length > 0) {
    // Create error summary
    const summary = document.createElement('div');
    summary.setAttribute('role', 'alert');
    summary.setAttribute('tabindex', '-1');
    summary.innerHTML = `
      <h2>Please fix the following errors:</h2>
      <ul>
        ${errors.map(e => `<li><a href="#${e.fieldId}">${e.message}</a></li>`).join('')}
      </ul>
    `;
    
    form.prepend(summary);
    
    // Focus error summary
    summary.focus();
    
    return false;
  }
  
  return true;
}

React Focus Management

useFocusTrap Hook

import { useEffect, useRef } from 'react';

function useFocusTrap(isActive) {
  const containerRef = useRef(null);
  const previousFocus = useRef(null);
  
  useEffect(() => {
    if (isActive) {
      // Store previous focus
      previousFocus.current = document.activeElement;
      
      // Focus first element in container
      const container = containerRef.current;
      const focusable = container?.querySelector(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      );
      focusable?.focus();
      
      // Handle Tab key
      const handleKeyDown = (e) => {
        if (e.key !== 'Tab') return;
        
        const focusableElements = container.querySelectorAll(
          'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
        );
        
        const first = focusableElements[0];
        const last = focusableElements[focusableElements.length - 1];
        
        if (e.shiftKey && document.activeElement === first) {
          e.preventDefault();
          last.focus();
        } else if (!e.shiftKey && document.activeElement === last) {
          e.preventDefault();
          first.focus();
        }
      };
      
      document.addEventListener('keydown', handleKeyDown);
      
      return () => {
        document.removeEventListener('keydown', handleKeyDown);
        // Restore focus
        previousFocus.current?.focus();
      };
    }
  }, [isActive]);
  
  return containerRef;
}

Best Practices

Visible Focus Indicators

/* Never remove focus outline without replacement */
/* ❌ Bad */
*:focus {
  outline: none;
}

/* ✅ Good */
*:focus {
  outline: 2px solid #005fcc;
  outline-offset: 2px;
}

/* ✅ Alternative */
*:focus-visible {
  outline: 2px solid #005fcc;
  outline-offset: 2px;
}

Focus vs Click

// Don't move focus on click unless necessary
button.addEventListener('click', () => {
  // Don't do this unless needed
  // button.focus();
  
  // Do this
  openModal();
});
Key Takeaway

Manage focus to support keyboard navigation and screen reader users. Trap focus in modals, restore focus when closing, and move focus to new content. Always provide visible focus indicators and test with keyboard-only navigation.

Resources

Related Topics