Skip to content
Unverified — AI-generated content. Help verify this page

Focus Management ​

Focus management is the most complex aspect of keyboard accessibility. When UI changes dramatically — a modal opens, a page navigates, a panel expands — keyboard users need their focus guided to the right place. Without intentional focus management, focus is lost or trapped in invisible content.

The Focus Lifecycle ​

Every dynamic UI change requires answering:

  1. Where does focus go when this opens/appears?
  2. What happens to focus within this element?
  3. Where does focus go when this closes/disappears?

Focus Trapping ​

When a modal dialog is open, focus must be confined to it. If focus escapes the modal:

  • Screen reader users hear content outside the modal
  • Keyboard users interact with background content

The pattern: intercept Tab and Shift+Tab to cycle within the modal.

typescript
// focus-trap.ts

export class FocusTrap {
  private container: HTMLElement;
  private firstFocusable: HTMLElement | null = null;
  private lastFocusable: HTMLElement | null = null;
  private previouslyFocused: HTMLElement | null = null;
  private active = false;

  // All elements that are keyboard-focusable
  private static FOCUSABLE_SELECTORS = [
    'a[href]',
    'button:not([disabled])',
    'input:not([disabled]):not([type="hidden"])',
    'select:not([disabled])',
    'textarea:not([disabled])',
    '[tabindex]:not([tabindex="-1"])',
    'details > summary',
    'audio[controls]',
    'video[controls]',
    '[contenteditable]:not([contenteditable="false"])',
  ].join(', ');

  constructor(container: HTMLElement) {
    this.container = container;
  }

  private getFocusableElements(): HTMLElement[] {
    return Array.from(
      this.container.querySelectorAll<HTMLElement>(FocusTrap.FOCUSABLE_SELECTORS)
    ).filter(el => {
      // Exclude elements with aria-hidden ancestors
      let node: Element | null = el;
      while (node) {
        if (node.getAttribute('aria-hidden') === 'true') return false;
        node = node.parentElement;
      }
      return true;
    });
  }

  activate(initialFocus?: HTMLElement): void {
    if (this.active) return;

    this.previouslyFocused = document.activeElement as HTMLElement;
    this.active = true;

    // Find focusable elements
    const focusable = this.getFocusableElements();
    this.firstFocusable = focusable[0] ?? null;
    this.lastFocusable = focusable[focusable.length - 1] ?? null;

    // Move focus into the trap
    const focusTarget = initialFocus ?? this.firstFocusable ?? this.container;
    focusTarget.focus();

    // Trap keyboard events
    this.container.addEventListener('keydown', this.handleKeyDown);

    // Block focus from escaping via outside click
    document.addEventListener('focusin', this.handleFocusIn);
  }

  deactivate(restoreFocus = true): void {
    if (!this.active) return;

    this.active = false;
    this.container.removeEventListener('keydown', this.handleKeyDown);
    document.removeEventListener('focusin', this.handleFocusIn);

    if (restoreFocus && this.previouslyFocused) {
      // Restore focus to the element that was focused before trap activated
      this.previouslyFocused.focus({ preventScroll: true });
    }
  }

  private handleKeyDown = (e: KeyboardEvent): void => {
    if (e.key !== 'Tab') return;

    // Refresh focusable list on each Tab (DOM may have changed)
    const focusable = this.getFocusableElements();
    const first = focusable[0];
    const last = focusable[focusable.length - 1];

    if (!first || !last) return;

    const isShiftTab = e.shiftKey;
    const isFirstFocused = document.activeElement === first;
    const isLastFocused = document.activeElement === last;

    if (isShiftTab && isFirstFocused) {
      // Wrap: Shift+Tab from first → go to last
      e.preventDefault();
      last.focus();
    } else if (!isShiftTab && isLastFocused) {
      // Wrap: Tab from last → go to first
      e.preventDefault();
      first.focus();
    }
    // Otherwise: natural tab order within the trap
  };

  private handleFocusIn = (e: FocusEvent): void => {
    if (!this.container.contains(e.target as Node)) {
      // Focus escaped the container (e.g., via programmatic focus elsewhere)
      e.stopPropagation();
      const focusable = this.getFocusableElements();
      if (focusable.length > 0) {
        focusable[0].focus();
      } else {
        this.container.focus();
      }
    }
  };
}
tsx
// components/Modal/Modal.tsx
import React, { useEffect, useRef, useCallback } from 'react';
import { FocusTrap } from '../../utils/focus-trap';
import { createPortal } from 'react-dom';
import styles from './Modal.module.css';

interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
  initialFocusRef?: React.RefObject<HTMLElement>;
  closeOnOverlayClick?: boolean;
  closeOnEscape?: boolean;
}

export function Modal({
  isOpen,
  onClose,
  title,
  children,
  initialFocusRef,
  closeOnOverlayClick = true,
  closeOnEscape = true,
}: ModalProps) {
  const dialogRef = useRef<HTMLDivElement>(null);
  const titleId = useRef(`modal-title-${Math.random().toString(36).slice(2)}`).current;
  const focusTrapRef = useRef<FocusTrap | null>(null);

  // Activate/deactivate focus trap
  useEffect(() => {
    if (!dialogRef.current) return;

    if (isOpen) {
      const trap = new FocusTrap(dialogRef.current);
      focusTrapRef.current = trap;

      // Focus initial element or first focusable
      trap.activate(initialFocusRef?.current ?? undefined);

      // Prevent body scroll while modal is open
      document.body.style.overflow = 'hidden';

      return () => {
        trap.deactivate(true); // Restore focus to trigger
        focusTrapRef.current = null;
        document.body.style.overflow = '';
      };
    }
  }, [isOpen, initialFocusRef]);

  // Escape key handler
  useEffect(() => {
    if (!isOpen || !closeOnEscape) return;

    const handleEscape = (e: KeyboardEvent) => {
      if (e.key === 'Escape') {
        e.stopPropagation();
        onClose();
      }
    };

    document.addEventListener('keydown', handleEscape, { capture: true });
    return () => document.removeEventListener('keydown', handleEscape, { capture: true });
  }, [isOpen, closeOnEscape, onClose]);

  const handleOverlayClick = useCallback((e: React.MouseEvent) => {
    if (closeOnOverlayClick && e.target === e.currentTarget) {
      onClose();
    }
  }, [closeOnOverlayClick, onClose]);

  if (!isOpen) return null;

  return createPortal(
    <div
      className={styles.overlay}
      onClick={handleOverlayClick}
      aria-hidden={!isOpen}
    >
      <div
        ref={dialogRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby={titleId}
        className={styles.dialog}
      >
        <div className={styles.header}>
          <h2 id={titleId} className={styles.title}>{title}</h2>
          <button
            type="button"
            className={styles.closeButton}
            onClick={onClose}
            aria-label="Close dialog"
          >
            <span aria-hidden="true">×</span>
          </button>
        </div>
        <div className={styles.body}>
          {children}
        </div>
      </div>
    </div>,
    document.body
  );
}

Focus Restoration ​

When a component closes, focus must return to the element that triggered it:

typescript
// hooks/useFocusReturn.ts
import { useRef, useEffect } from 'react';

/**
 * Returns focus to the previously focused element when the component unmounts
 * or when `shouldReturn` changes from true to false.
 */
export function useFocusReturn(shouldReturn: boolean): void {
  const savedFocusRef = useRef<HTMLElement | null>(null);

  useEffect(() => {
    if (shouldReturn) {
      // Save current focus when component becomes active
      savedFocusRef.current = document.activeElement as HTMLElement;
    } else {
      // Restore focus when component becomes inactive
      if (savedFocusRef.current) {
        // Check if element is still in DOM and focusable
        if (document.contains(savedFocusRef.current)) {
          savedFocusRef.current.focus({ preventScroll: true });
        }
        savedFocusRef.current = null;
      }
    }
  }, [shouldReturn]);
}

// Usage with useEffect for cleanup
export function useFocusReturnOnUnmount(): void {
  const savedFocusRef = useRef<HTMLElement | null>(null);

  useEffect(() => {
    savedFocusRef.current = document.activeElement as HTMLElement;

    return () => {
      if (savedFocusRef.current && document.contains(savedFocusRef.current)) {
        savedFocusRef.current.focus({ preventScroll: true });
      }
    };
  }, []);
}

The :focus-visible Pseudo-class ​

:focus-visible applies only when the browser determines the user is navigating by keyboard (not mouse):

css
/* Remove default focus ring */
:focus {
  outline: none;
}

/* Add custom ring only for keyboard navigation */
:focus-visible {
  outline: 2px solid var(--color-border-focus);
  outline-offset: 2px;
}

/* Enhanced double-ring pattern */
:focus-visible {
  outline: 2px solid var(--color-border-focus);
  outline-offset: 2px;
  box-shadow: 0 0 0 4px color-mix(in oklch, var(--color-border-focus) 25%, transparent);
}

/* Button-specific focus */
.button:focus-visible {
  outline: 2px solid var(--color-action-primary);
  outline-offset: 3px;
}

/* Input-specific focus */
.input:focus-visible {
  outline: 2px solid var(--color-border-focus);
  border-color: var(--color-border-focus);
  box-shadow: 0 0 0 3px color-mix(in oklch, var(--color-border-focus) 20%, transparent);
}

Focus Within Dark Backgrounds ​

The double-ring pattern ensures focus is visible on any background:

css
/* Method 1: Hard white offset */
:focus-visible {
  outline: 2px solid var(--color-brand);
  outline-offset: 2px;
  /* Inner white ring contrasts against dark, outer blue ring contrasts against white */
  box-shadow:
    0 0 0 2px white,
    0 0 0 4px var(--color-brand);
}

/* Method 2: Use HSL transparency */
:focus-visible {
  outline: 2px solid var(--color-brand);
  outline-offset: 4px;
}

/* Invert for dark backgrounds using data attribute */
[data-theme="dark"] :focus-visible {
  outline-color: oklch(80% 0.15 264); /* Lighter blue for dark bg */
}

Managing Focus in React Route Transitions ​

tsx
// components/RouteAnnouncer.tsx
// Focus the new page title after navigation

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

export function RouteAnnouncer() {
  const location = useLocation();
  const headingRef = useRef<HTMLHeadingElement | null>(null);

  useEffect(() => {
    // After route change, focus the main heading
    const h1 = document.querySelector<HTMLHeadingElement>('main h1');
    if (h1) {
      h1.tabIndex = -1;
      h1.focus({ preventScroll: false }); // Allow scroll to top

      // Remove tabindex after focus (don't pollute tab order)
      h1.addEventListener('blur', () => {
        h1.removeAttribute('tabindex');
      }, { once: true });
    }
  }, [location.pathname]);

  return null;
}

Combobox / Autocomplete Focus Pattern ​

The combobox pattern is one of the most complex focus management scenarios:

typescript
// Combobox focus states:
// 1. Input focused, dropdown closed (normal state)
// 2. Input focused, dropdown open (typing/browsing)
// 3. Option hovered (focus stays on input, aria-activedescendant changes)
// 4. Option selected (dropdown closes, focus stays on input)

class ComboboxFocusManager {
  private input: HTMLInputElement;
  private listbox: HTMLElement;
  private activeId: string | null = null;

  constructor(input: HTMLInputElement, listbox: HTMLElement) {
    this.input = input;
    this.listbox = listbox;
    this.setup();
  }

  private setup(): void {
    this.input.setAttribute('role', 'combobox');
    this.input.setAttribute('aria-expanded', 'false');
    this.input.setAttribute('aria-haspopup', 'listbox');
    this.input.setAttribute('aria-autocomplete', 'list');
    this.input.setAttribute('aria-controls', this.listbox.id);

    this.input.addEventListener('keydown', this.handleKeyDown);
  }

  private handleKeyDown = (e: KeyboardEvent): void => {
    const isExpanded = this.input.getAttribute('aria-expanded') === 'true';

    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        if (!isExpanded) this.openListbox();
        this.moveSelection(1);
        break;

      case 'ArrowUp':
        e.preventDefault();
        if (!isExpanded) this.openListbox();
        this.moveSelection(-1);
        break;

      case 'Enter':
        if (isExpanded && this.activeId) {
          e.preventDefault();
          this.selectOption(this.activeId);
        }
        break;

      case 'Escape':
        if (isExpanded) {
          e.preventDefault();
          this.closeListbox();
          // Focus stays on input — don't move it
        }
        break;

      case 'Tab':
        if (isExpanded) this.closeListbox();
        // Don't prevent default — allow Tab to move focus naturally
        break;
    }
  };

  private moveSelection(direction: 1 | -1): void {
    const options = Array.from(
      this.listbox.querySelectorAll<HTMLElement>('[role="option"]:not([aria-disabled="true"])')
    );

    if (options.length === 0) return;

    const currentIndex = this.activeId
      ? options.findIndex(o => o.id === this.activeId)
      : -1;

    const newIndex = Math.max(0, Math.min(
      options.length - 1,
      currentIndex + direction
    ));

    const newOption = options[newIndex];
    this.setActiveOption(newOption);
  }

  setActiveOption(option: HTMLElement): void {
    // Remove previous active indicator
    if (this.activeId) {
      document.getElementById(this.activeId)?.removeAttribute('aria-selected');
    }

    this.activeId = option.id;
    option.setAttribute('aria-selected', 'true');

    // aria-activedescendant: focus visually stays on input
    // but screen reader announces the option
    this.input.setAttribute('aria-activedescendant', option.id);

    // Scroll option into view
    option.scrollIntoView({ block: 'nearest' });
  }

  openListbox(): void {
    this.listbox.removeAttribute('hidden');
    this.input.setAttribute('aria-expanded', 'true');
  }

  closeListbox(): void {
    this.listbox.setAttribute('hidden', '');
    this.input.setAttribute('aria-expanded', 'false');
    this.input.removeAttribute('aria-activedescendant');
    this.activeId = null;
  }

  private selectOption(optionId: string): void {
    const option = document.getElementById(optionId);
    if (!option) return;

    const value = option.textContent?.trim() ?? '';
    this.input.value = value;
    this.closeListbox();

    // Dispatch change event for React controlled components
    const event = new Event('input', { bubbles: true });
    this.input.dispatchEvent(event);
  }
}

War Story

A design system team shipped a popover menu component where closing the popover sent focus to document.body (the default when the trigger was removed from DOM). Screen reader users who opened a menu in the middle of a long list found themselves at the top of the page after closing it — they had to re-navigate the entire page to continue. The fix was a 3-line useEffect that saved the triggering element's ref and restored focus to it on close. This is the most common focus management bug in SPAs.

"What I cannot create, I do not understand." — Richard Feynman