Skip to main content
Technology & EngineeringAccessibility204 lines

Focus Management

Focus management strategies for single-page applications, modals, route changes, and dynamic content

Quick Summary18 lines
You are an expert in focus management for building accessible single-page applications.

## Key Points

- **Route/view changes**: After client-side navigation, focus should move to the new content.
- **Modal dialogs**: Focus must be trapped inside the modal and restored when it closes.
- **Dynamic content insertion**: When content appears (toast notifications, inline expansions, search results), focus or an announcement must inform the user.
- **Element removal**: When the focused element is removed from the DOM, focus must be moved to a logical successor.
- **Multi-step flows**: Wizards and steppers should move focus to the new step heading or first interactive element.
- After every client-side navigation, confirm that focus moves to a meaningful element (heading or main content area) and the screen reader announces the new context.
- Open a modal, Tab through every focusable element, and confirm focus wraps within the modal without escaping.
- Close the modal and verify focus returns to the element that triggered it.
- Delete items from a list and verify focus moves to a logical successor rather than resetting to the top of the page.
- Test with NVDA or VoiceOver to confirm that announcements fire at the right time.
- Use the native `<dialog>` element with `showModal()` when possible; it provides built-in focus trapping and Escape key handling.
- Set `tabindex="-1"` on non-interactive targets (like headings) that receive programmatic focus so they do not appear in the regular Tab order.
skilldb get accessibility-skills/Focus ManagementFull skill: 204 lines
Paste into your CLAUDE.md or agent config

Focus Management — Web Accessibility

You are an expert in focus management for building accessible single-page applications.

Core Philosophy

Overview

In traditional multi-page websites, the browser resets focus to the top of the page on navigation. Single-page applications (SPAs) update content without a full page reload, which means focus can be left on a stale element, a removed element, or nowhere meaningful. Proper focus management ensures keyboard and screen reader users always know where they are and what changed.

Core Concepts

When focus management is needed

  • Route/view changes: After client-side navigation, focus should move to the new content.
  • Modal dialogs: Focus must be trapped inside the modal and restored when it closes.
  • Dynamic content insertion: When content appears (toast notifications, inline expansions, search results), focus or an announcement must inform the user.
  • Element removal: When the focused element is removed from the DOM, focus must be moved to a logical successor.
  • Multi-step flows: Wizards and steppers should move focus to the new step heading or first interactive element.

tabindex values

ValueMeaning
tabindex="0"Element is focusable in natural DOM order
tabindex="-1"Element is focusable programmatically but not via Tab
tabindex="1+"Forces focus order position — avoid this

Implementation Patterns

Focus on route change (React)

import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';

function PageWrapper({ title, children }) {
  const headingRef = useRef(null);
  const location = useLocation();

  useEffect(() => {
    document.title = title;
    // Move focus to the page heading after route change
    headingRef.current?.focus();
  }, [location.pathname, title]);

  return (
    <main>
      <h1 ref={headingRef} tabIndex={-1}>
        {title}
      </h1>
      {children}
    </main>
  );
}

Focus trap for modals

function trapFocus(modalElement) {
  const focusableSelectors = [
    'a[href]', 'button:not([disabled])', 'input:not([disabled])',
    'select:not([disabled])', 'textarea:not([disabled])',
    '[tabindex]:not([tabindex="-1"])',
  ];

  const focusableElements = modalElement.querySelectorAll(
    focusableSelectors.join(', ')
  );
  const firstFocusable = focusableElements[0];
  const lastFocusable = focusableElements[focusableElements.length - 1];

  function handleKeydown(e) {
    if (e.key !== 'Tab') return;

    if (e.shiftKey) {
      if (document.activeElement === firstFocusable) {
        e.preventDefault();
        lastFocusable.focus();
      }
    } else {
      if (document.activeElement === lastFocusable) {
        e.preventDefault();
        firstFocusable.focus();
      }
    }
  }

  modalElement.addEventListener('keydown', handleKeydown);

  // Focus the first element
  firstFocusable?.focus();

  // Return cleanup function
  return () => modalElement.removeEventListener('keydown', handleKeydown);
}

Using the native dialog element

<dialog id="confirm-dialog" aria-labelledby="dialog-heading">
  <h2 id="dialog-heading">Confirm action</h2>
  <p>Are you sure you want to proceed?</p>
  <button id="cancel-btn">Cancel</button>
  <button id="confirm-btn">Confirm</button>
</dialog>
const dialog = document.getElementById('confirm-dialog');
const openButton = document.getElementById('open-dialog');

openButton.addEventListener('click', () => {
  dialog.showModal(); // Native focus trap and Escape handling
});

dialog.addEventListener('close', () => {
  openButton.focus(); // Restore focus to the trigger
});

Focus restoration after element removal

function deleteItem(itemElement, listElement) {
  const items = Array.from(listElement.children);
  const index = items.indexOf(itemElement);

  itemElement.remove();

  // Focus the next item, or the previous if last was deleted, or the list itself
  const remaining = Array.from(listElement.children);
  if (remaining[index]) {
    remaining[index].focus();
  } else if (remaining[index - 1]) {
    remaining[index - 1].focus();
  } else {
    listElement.focus(); // Empty list — focus the container
  }
}

Announcing route changes with a live region

function RouteAnnouncer() {
  const [announcement, setAnnouncement] = useState('');
  const location = useLocation();

  useEffect(() => {
    setAnnouncement(`Navigated to ${document.title}`);
  }, [location.pathname]);

  return (
    <div
      role="status"
      aria-live="polite"
      aria-atomic="true"
      className="sr-only"
    >
      {announcement}
    </div>
  );
}

Testing & Validation

  • After every client-side navigation, confirm that focus moves to a meaningful element (heading or main content area) and the screen reader announces the new context.
  • Open a modal, Tab through every focusable element, and confirm focus wraps within the modal without escaping.
  • Close the modal and verify focus returns to the element that triggered it.
  • Delete items from a list and verify focus moves to a logical successor rather than resetting to the top of the page.
  • Test with NVDA or VoiceOver to confirm that announcements fire at the right time.

Best Practices

  • Use the native <dialog> element with showModal() when possible; it provides built-in focus trapping and Escape key handling.
  • Set tabindex="-1" on non-interactive targets (like headings) that receive programmatic focus so they do not appear in the regular Tab order.
  • Always restore focus to the triggering element when closing overlays, popovers, or dialogs.

Common Pitfalls

  • Failing to manage focus on route changes, leaving the user stranded on a button or link that no longer corresponds to the visible content.
  • Calling .focus() on an element before it is rendered in the DOM; use requestAnimationFrame or useEffect to defer the call.

Anti-Patterns

Over-engineering for hypothetical scale. Building for millions of users when you have hundreds adds complexity without value. Solve today's problems first.

Ignoring the existing ecosystem. Reinventing functionality that mature libraries already provide well wastes time and introduces unnecessary risk.

Premature abstraction. Creating elaborate frameworks and utilities before you have enough concrete cases to know what the abstraction should look like produces the wrong abstraction.

Neglecting error handling at boundaries. Internal code can trust its inputs, but system boundaries (user input, APIs, file I/O) require defensive validation.

Skipping documentation for obvious code. What is obvious to you today will not be obvious to your colleague next month or to you next year.

Install this skill directly: skilldb add accessibility-skills

Get CLI access →