Front-end Engineering Lab

Focus Management in SPAs

Handle focus correctly during route changes and dynamic updates

In traditional multi-page apps, focus resets automatically on navigation. In SPAs, you must manage focus manually to maintain accessibility for keyboard and screen reader users.

The Problem

// ❌ BAD: No focus management
function App() {
  return (
    <Routes>
      <Route path="/" element={<Home />} />
      <Route path="/about" element={<About />} />
    </Routes>
  );
}

// User clicks link, route changes
// Focus stays on the link
// Screen reader doesn't announce new page
// Keyboard users confused

The Solution

// ✅ GOOD: Focus management
function App() {
  const location = useLocation();
  const mainRef = useRef<HTMLElement>(null);

  useEffect(() => {
    // Move focus to main content on route change
    mainRef.current?.focus();
    
    // Scroll to top
    window.scrollTo(0, 0);
  }, [location]);

  return (
    <main ref={mainRef} tabIndex={-1} style={{ outline: 'none' }}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
      </Routes>
    </main>
  );
}
// Allow users to skip navigation
export function Layout({ children }: Props) {
  return (
    <>
      <a href="#main-content" className="skip-link">
        Skip to main content
      </a>
      
      <header>
        <nav>...</nav>
      </header>
      
      <main id="main-content" tabIndex={-1}>
        {children}
      </main>
    </>
  );
}

// CSS
.skip-link {
  position: absolute;
  top: -40px;
  left: 0;
  background: #000;
  color: #fff;
  padding: 8px;
  z-index: 100;
}

.skip-link:focus {
  top: 0;
}

Focus on Route Change

// hooks/useFocusOnRouteChange.ts
export function useFocusOnRouteChange(ref: RefObject<HTMLElement>) {
  const location = useLocation();
  const prevLocation = useRef(location);

  useEffect(() => {
    // Only focus if route actually changed
    if (location.pathname !== prevLocation.current.pathname) {
      ref.current?.focus();
      prevLocation.current = location;
    }
  }, [location, ref]);
}

// Usage
export function Page() {
  const mainRef = useRef<HTMLElement>(null);
  useFocusOnRouteChange(mainRef);

  return (
    <main ref={mainRef} tabIndex={-1}>
      <h1>Page Content</h1>
    </main>
  );
}
// components/Modal.tsx
export function Modal({ isOpen, onClose, children }: Props) {
  const modalRef = useRef<HTMLDivElement>(null);
  const previousFocus = useRef<HTMLElement | null>(null);

  useEffect(() => {
    if (isOpen) {
      // Save current focus
      previousFocus.current = document.activeElement as HTMLElement;
      
      // Focus first element in modal
      const firstFocusable = modalRef.current?.querySelector<HTMLElement>(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      );
      firstFocusable?.focus();
      
      // Trap focus
      const handleTab = (e: KeyboardEvent) => {
        const focusableElements = modalRef.current?.querySelectorAll<HTMLElement>(
          'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
        );
        
        if (!focusableElements) return;
        
        const firstElement = focusableElements[0];
        const lastElement = focusableElements[focusableElements.length - 1];
        
        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();
          }
        }
      };
      
      document.addEventListener('keydown', handleTab);
      
      return () => {
        document.removeEventListener('keydown', handleTab);
        
        // Restore focus on close
        previousFocus.current?.focus();
      };
    }
  }, [isOpen]);

  if (!isOpen) return null;

  return (
    <div
      ref={modalRef}
      role="dialog"
      aria-modal="true"
      aria-labelledby="modal-title"
    >
      <h2 id="modal-title">Modal Title</h2>
      {children}
      <button onClick={onClose}>Close</button>
    </div>
  );
}

Focus Trap Hook

// hooks/useFocusTrap.ts
export function useFocusTrap(ref: RefObject<HTMLElement>, active: boolean) {
  useEffect(() => {
    if (!active || !ref.current) return;

    const element = ref.current;
    const focusableSelector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
    
    // Get focusable elements
    const focusableElements = Array.from(
      element.querySelectorAll<HTMLElement>(focusableSelector)
    );
    
    const firstElement = focusableElements[0];
    const lastElement = focusableElements[focusableElements.length - 1];
    
    // Focus first element
    firstElement?.focus();
    
    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key !== 'Tab') return;
      
      if (e.shiftKey) {
        // Shift + Tab
        if (document.activeElement === firstElement) {
          e.preventDefault();
          lastElement?.focus();
        }
      } else {
        // Tab
        if (document.activeElement === lastElement) {
          e.preventDefault();
          firstElement?.focus();
        }
      }
    };
    
    element.addEventListener('keydown', handleKeyDown);
    
    return () => {
      element.removeEventListener('keydown', handleKeyDown);
    };
  }, [ref, active]);
}

// Usage
export function Dialog({ isOpen, onClose }: Props) {
  const dialogRef = useRef<HTMLDivElement>(null);
  useFocusTrap(dialogRef, isOpen);

  return (
    <div ref={dialogRef} role="dialog">
      {/* content */}
    </div>
  );
}

Managing Focus After Actions

// Delete item and focus next item
export function ItemList() {
  const [items, setItems] = useState([...]);
  const itemRefs = useRef<Map<string, HTMLButtonElement>>(new Map());

  const deleteItem = (id: string, index: number) => {
    setItems(prev => prev.filter(item => item.id !== id));
    
    // Focus next item or previous
    const nextIndex = Math.min(index, items.length - 2);
    const nextId = items[nextIndex]?.id;
    
    if (nextId) {
      setTimeout(() => {
        itemRefs.current.get(nextId)?.focus();
      }, 0);
    }
  };

  return (
    <ul>
      {items.map((item, index) => (
        <li key={item.id}>
          <span>{item.name}</span>
          <button
            ref={el => {
              if (el) itemRefs.current.set(item.id, el);
            }}
            onClick={() => deleteItem(item.id, index)}
          >
            Delete
          </button>
        </li>
      ))}
    </ul>
  );
}

Form Submission Errors

export function FormWithFocusManagement() {
  const [errors, setErrors] = useState<Record<string, string>>({});
  const errorRefs = useRef<Map<string, HTMLInputElement>>(new Map());

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    
    // Validate
    const newErrors = validate(formData);
    setErrors(newErrors);
    
    // Focus first error
    if (Object.keys(newErrors).length > 0) {
      const firstErrorField = Object.keys(newErrors)[0];
      const input = errorRefs.current.get(firstErrorField);
      
      input?.focus();
      input?.scrollIntoView({ behavior: 'smooth', block: 'center' });
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          ref={el => {
            if (el) errorRefs.current.set('email', el);
          }}
          aria-invalid={!!errors.email}
          aria-describedby={errors.email ? 'email-error' : undefined}
        />
        {errors.email && (
          <div id="email-error" role="alert">
            {errors.email}
          </div>
        )}
      </div>
      
      <button type="submit">Submit</button>
    </form>
  );
}
export function DropdownMenu() {
  const [isOpen, setIsOpen] = useState(false);
  const menuRef = useRef<HTMLDivElement>(null);
  const buttonRef = useRef<HTMLButtonElement>(null);

  useEffect(() => {
    if (isOpen) {
      // Focus first menu item
      const firstItem = menuRef.current?.querySelector<HTMLButtonElement>('[role="menuitem"]');
      firstItem?.focus();
    }
  }, [isOpen]);

  const handleClose = () => {
    setIsOpen(false);
    // Return focus to button
    buttonRef.current?.focus();
  };

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === 'Escape') {
      handleClose();
    } else if (e.key === 'ArrowDown') {
      e.preventDefault();
      // Focus next item
      const current = document.activeElement as HTMLElement;
      const next = current.nextElementSibling as HTMLElement;
      next?.focus();
    } else if (e.key === 'ArrowUp') {
      e.preventDefault();
      // Focus previous item
      const current = document.activeElement as HTMLElement;
      const prev = current.previousElementSibling as HTMLElement;
      prev?.focus();
    }
  };

  return (
    <div>
      <button
        ref={buttonRef}
        onClick={() => setIsOpen(!isOpen)}
        aria-expanded={isOpen}
        aria-haspopup="menu"
      >
        Menu
      </button>
      
      {isOpen && (
        <div
          ref={menuRef}
          role="menu"
          onKeyDown={handleKeyDown}
        >
          <button role="menuitem" onClick={handleClose}>Item 1</button>
          <button role="menuitem" onClick={handleClose}>Item 2</button>
          <button role="menuitem" onClick={handleClose}>Item 3</button>
        </div>
      )}
    </div>
  );
}

Tabs with Keyboard Navigation

export function Tabs() {
  const [activeTab, setActiveTab] = useState(0);
  const tabRefs = useRef<HTMLButtonElement[]>([]);

  const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
    let newIndex = index;
    
    if (e.key === 'ArrowRight') {
      e.preventDefault();
      newIndex = (index + 1) % tabRefs.current.length;
    } else if (e.key === 'ArrowLeft') {
      e.preventDefault();
      newIndex = (index - 1 + tabRefs.current.length) % tabRefs.current.length;
    } else if (e.key === 'Home') {
      e.preventDefault();
      newIndex = 0;
    } else if (e.key === 'End') {
      e.preventDefault();
      newIndex = tabRefs.current.length - 1;
    }
    
    if (newIndex !== index) {
      setActiveTab(newIndex);
      tabRefs.current[newIndex]?.focus();
    }
  };

  return (
    <div>
      <div role="tablist">
        {tabs.map((tab, index) => (
          <button
            key={tab.id}
            ref={el => {
              if (el) tabRefs.current[index] = el;
            }}
            role="tab"
            aria-selected={activeTab === index}
            aria-controls={`panel-${tab.id}`}
            tabIndex={activeTab === index ? 0 : -1}
            onClick={() => setActiveTab(index)}
            onKeyDown={(e) => handleKeyDown(e, index)}
          >
            {tab.label}
          </button>
        ))}
      </div>
      
      {tabs.map((tab, index) => (
        <div
          key={tab.id}
          id={`panel-${tab.id}`}
          role="tabpanel"
          hidden={activeTab !== index}
          tabIndex={0}
        >
          {tab.content}
        </div>
      ))}
    </div>
  );
}

Best Practices

  1. Focus main content on route change
  2. Restore focus after modal close
  3. Trap focus in modals/dialogs
  4. Focus first error on form submission
  5. Skip links for main content
  6. Keyboard navigation for custom widgets
  7. No focus outline removal (use :focus-visible)
  8. Test with keyboard only
  9. Announce route changes with live regions
  10. Scroll to focused element if needed

Common Pitfalls

No focus management: Lost context
Always manage focus on route changes

Removing outlines: outline: none
Use :focus-visible for styling

No focus trap in modals: Can escape
Trap focus, restore on close

Not focusing errors: User confused
Focus first error field

Breaking tab order: tabindex > 0
Use semantic HTML, tabindex="-1" only

Focus management is critical for SPA accessibility—always handle focus explicitly on dynamic changes!

On this page