Skip to content
Back to Quality Assurance

Focus Management

Move, trap, and restore focus for dynamic content so keyboard and screen reader users always know where they are.

Why

Focus is the keyboard user's cursor — it is the element that will receive the next keystroke, and it must be visible and in the right place. When a modal opens, focus must move into the modal (otherwise the user is Tabbing through the page behind it). When the modal closes, focus must return to the trigger (otherwise the user is lost, at the top of the page). When a single-page app navigates, focus must move to the new content (otherwise the screen reader continues reading the old page). When a list filters, focus must move to the first result (otherwise the user does not know the list changed). Focus management is invisible to mouse users but essential to keyboard users — without it, the app is a maze where the user does not know where they are or how to get back.

When

Every dynamic content change: modal open/close, route navigation, tab switch, list filter, toast appearance, accordion expand.

How

1. Move focus when content changes
   - Modal opens: focus the first interactive element (or the modal container)
   - Route changes: focus the <main> or <h1> of the new page
   - Tab switches: focus the selected tab's panel
   - List filters: focus the first result, or announce "N results" with aria-live

2. Trap focus within a container while it is open
   - Modal: Tab should cycle within the modal, not escape to the page behind
   - Use a focus-trap library (focus-trap-react, @headlessui/react) or implement manually
   - Manual: on Tab at the last element, move to the first; on Shift+Tab at the first, move to the last

3. Restore focus when the container closes
   - Modal closes: return focus to the button that opened it
   - This lets the user continue from where they were, not from the page top
   - Store the trigger element in a ref before opening, restore on close

4. Make the focus target focusable
   - A <div> is not focusable by default — add tabIndex={-1} to make it programmatically focusable
   - tabIndex={-1} is focusable via .focus() but not via Tab (correct for modal containers)
   - tabIndex={0} is focusable via both (use for elements that should be in the tab order)

5. Announce changes that do not move focus
   - Some changes should not steal focus (toast, cart update, background refresh)
   - Use aria-live="polite" to announce without moving focus
   - Use aria-live="assertive" (or role="alert") for urgent announcements (errors)

Example

import { useRef, useEffect } from "react";

function Modal({ isOpen, onClose, children }) {
  const modalRef = useRef(null);
  const triggerRef = useRef(null);

  useEffect(() => {
    if (isOpen) {
      // Store the currently focused element (the trigger) before opening
      triggerRef.current = document.activeElement as HTMLElement;
      // Move focus into the modal
      modalRef.current?.focus();
    } else if (triggerRef.current) {
      // Restore focus to the trigger on close
      triggerRef.current.focus();
      triggerRef.current = null;
    }
  }, [isOpen]);

  if (!isOpen) return null;
  return (
    <div role="dialog" aria-modal="true" ref={modalRef} tabIndex={-1}>
      {children}
      <button onClick={onClose}>Close</button>
    </div>
  );
}

// Route change: move focus to the new page's main content
function Page({ title, children }) {
  const mainRef = useRef(null);
  useEffect(() => {
    // Move focus to <main> on every route change
    mainRef.current?.focus();
  }, [title]); // depends on the page
  return (
    <main ref={mainRef} tabIndex={-1}>
      <h1>{title}</h1>
      {children}
    </main>
  );
}

// Focus trap: keep Tab within the modal
import FocusTrap from "focus-trap-react";
function Modal({ isOpen, children }) {
  if (!isOpen) return null;
  return (
    <FocusTrap>
      <div role="dialog" aria-modal="true">
        {children}
      </div>
    </FocusTrap>
  );
}

// Announce without moving focus (toast)
function Toast({ message }) {
  return (
    <div role="status" aria-live="polite" className="toast">
      {message}
    </div>
  );
}
// The toast appears, the screen reader announces it, but focus stays where the user was

Common Problems

  • ⚠Opening a modal without moving focus into it — the screen reader continues reading the page behind the modal, and the keyboard user Tabs through invisible elements, making the modal unusable.
  • ⚠Closing a modal without restoring focus to the trigger — the focus jumps to the top of the page, and the keyboard user is lost, having to Tab through the entire page to get back to where they were.
  • ⚠Not trapping focus within the modal — Tab escapes to the page behind, the user Tabs into content they cannot see, and the modal is not a self-contained experience.
  • ⚠Moving focus for non-urgent announcements (toast, cart update) — the focus is stolen from the user's current task, and they are disoriented by the unexpected focus change.

Best Practices

  • ✓Always move focus into a modal when it opens and restore it to the trigger when it closes — this is the single most important focus management pattern, and it is what makes a modal usable for keyboard and screen reader users.
  • ✓Use a focus-trap library (focus-trap-react, @headlessui/react Dialog) for modals — manual focus trapping is error-prone (edge cases with iframes, shadow DOM, dynamically added elements), and the library handles them.
  • ✓Move focus to the new page's <main> or <h1> on route change in a single-page app — otherwise the screen reader continues reading the old page, and the user does not know the page changed.
  • ✓Use aria-live for announcements that should not move focus (toasts, cart updates, background refreshes) — the announcement reaches the user without disorienting them with a focus change.