Skip to main content

Welcome to our new website! The content and organization have been heavily redone and we want to hear from you! 
Submit feedback

Focus Management

Controlling keyboard focus in dynamic web applications.

🎯 WCAG 2.4.3, 2.4.7, 2.4.11

What is focus management?

Focus management is the practice of programmatically controlling where keyboard focus goes in response to user actions. It's critical for:

  • Single-page applications (SPAs)
  • Modal dialogs and overlays
  • Dynamic content updates
  • Error handling in forms
  • Tab interfaces and accordions

Poor focus management can leave keyboard users lost on a page, unable to continue their task.

Focus indicators (WCAG 2.4.7)

Focus indicators show which element currently has keyboard focus.

Requirements

  • Must be visible for all focusable elements
  • Should have sufficient contrast against all backgrounds
  • Should not be removed without replacement

Recommended CSS approach

/* Use :focus-visible for keyboard-only focus */
:focus-visible {
  outline: 3px solid #0C234B;
  outline-offset: 2px;
}

/* Fallback for browsers without :focus-visible */
:focus:not(:focus-visible) {
  outline: none;
}

/* High contrast for dark backgrounds */
.dark-bg :focus-visible {
  outline-color: #FFD700;
}

Never do this

/* ❌ Removes all focus indicators */
*:focus {
  outline: none;
}

/* ❌ Hides focus on specific elements */
a:focus, button:focus {
  outline: 0;
}

Focus order (WCAG 2.4.3)

Focus order should match the visual reading order and preserve meaning.

Best practices

  • Use semantic HTML in logical source order
  • Avoid positive tabindex values (1, 2, 3...)
  • Test by tabbing through without mouse
  • Ensure CSS layout doesn't create visual/focus mismatch

CSS layout considerations

/* Flexbox can reorder visually but not focus order */
.flex-container {
  display: flex;
  flex-direction: row-reverse; /* Visual order changed */
}
/* Focus still follows DOM order - may confuse users */

/* If you must reorder, consider matching tabindex or DOM order */

Focus not obscured (WCAG 2.4.11)

New in WCAG 2.2: When an element receives focus, it must not be entirely hidden by other content.

Common problems

  • Sticky headers covering focused elements
  • Cookie consent banners
  • Chat widgets
  • Fixed navigation

Solutions

/* Use scroll-margin to offset for sticky headers */
[id] {
  scroll-margin-top: 80px; /* Height of sticky header */
}

/* Or scroll-padding on the container */
html {
  scroll-padding-top: 80px;
}

/* Ensure modals don't cover focused content behind them */
.modal-open {
  /* Trap focus inside modal instead */
}

Modal dialogs require careful focus management to be accessible.

Requirements

  1. On open: Move focus into the modal (first focusable element or modal itself)
  2. Focus trap: Keep focus inside modal while open
  3. On close: Return focus to the element that opened the modal
  4. Escape key: Close modal and return focus

Implementation pattern

// Store trigger element
let triggerElement;

function openModal(modal) {
  triggerElement = document.activeElement;
  modal.setAttribute('aria-hidden', 'false');
  modal.style.display = 'block';
  
  // Focus first focusable element or modal
  const firstFocusable = modal.querySelector(
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
  );
  if (firstFocusable) {
    firstFocusable.focus();
  } else {
    modal.setAttribute('tabindex', '-1');
    modal.focus();
  }
}

function closeModal(modal) {
  modal.setAttribute('aria-hidden', 'true');
  modal.style.display = 'none';
  
  // Return focus to trigger
  if (triggerElement) {
    triggerElement.focus();
  }
}

Focus trap implementation

function trapFocus(modal) {
  const focusableElements = modal.querySelectorAll(
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
  );
  const firstElement = focusableElements[0];
  const lastElement = focusableElements[focusableElements.length - 1];

  modal.addEventListener('keydown', function(e) {
    if (e.key === 'Tab') {
      if (e.shiftKey && document.activeElement === firstElement) {
        e.preventDefault();
        lastElement.focus();
      } else if (!e.shiftKey && document.activeElement === lastElement) {
        e.preventDefault();
        firstElement.focus();
      }
    }
    if (e.key === 'Escape') {
      closeModal(modal);
    }
  });
}

Single-page application focus

SPAs don't trigger page loads, so focus management must be handled manually.

Route change patterns

  • Focus heading: Move focus to the new page's H1
  • Focus container: Focus main content area with tabindex="-1"
  • Announce change: Use ARIA live region to announce new page

Example: React route change

// Focus main content on route change
useEffect(() => {
  const mainContent = document.getElementById('maincontent');
  if (mainContent) {
    mainContent.tabIndex = -1;
    mainContent.focus();
    // Remove tabindex after focus to prevent re-focus issues
    mainContent.addEventListener('blur', () => {
      mainContent.removeAttribute('tabindex');
    }, { once: true });
  }
}, [location.pathname]);

Dynamic content focus

Form error handling

When form validation fails:

  1. Move focus to error summary or first error
  2. Announce errors to screen readers
  3. Link errors to specific fields
function handleFormErrors(errors) {
  // Create or update error summary
  const summary = document.getElementById('error-summary');
  summary.innerHTML = `<h2>${errors.length} errors found</h2>...`;
  
  // Focus error summary
  summary.tabIndex = -1;
  summary.focus();
  
  // Announce to screen readers
  summary.setAttribute('role', 'alert');
}

Infinite scroll / Load more

When new content loads:

  • Don't automatically move focus
  • Announce new content with live region
  • Keep focus on "Load more" button

Delete actions

When an item is deleted:

  • Move focus to next item in list
  • Or move to previous item if last was deleted
  • Or move to container if list is empty

Testing focus management

Manual testing

  1. Tab through entire page without mouse
  2. Open/close modals and verify focus returns
  3. Trigger route changes and check focus
  4. Submit forms with errors
  5. Verify focus is never lost off-screen

Automated tools

  • Accessibility Insights: Tab stops visualization
  • axe DevTools: Focus order issues
  • Browser DevTools: document.activeElement in console

Debug tip

// Log focus changes in console
document.addEventListener('focusin', (e) => {
  console.log('Focus moved to:', e.target);
});

Focus management checklist

  • check_box_outline_blank All focusable elements have visible focus indicator
  • check_box_outline_blank Focus order matches reading order
  • check_box_outline_blank Focus not obscured by sticky/fixed elements
  • check_box_outline_blank Modals trap focus and return focus on close
  • check_box_outline_blank Route changes move focus appropriately
  • check_box_outline_blank Form errors focus error summary or first error
  • check_box_outline_blank Dynamic content doesn't cause focus loss
  • check_box_outline_blank Escape key closes modals/menus
  • check_box_outline_blank No positive tabindex values used

Resources