Saved
Frontend Pattern

Focus Management

Control focus flow for modal dialogs, route changes, and dynamic content insertion.

Difficulty Beginner

By Den Odell

Focus Management

Problem

Keyboard users get lost when focus is not managed properly during UI changes. Opening a modal dialog leaves focus on the trigger button beneath it, forcing users to tab through dozens of elements in the page content behind the modal to reach the close button or form fields inside the modal, while after navigating to a new route via client-side routing, focus remains on the navigation link at the bottom of the previous page, requiring keyboard users to tab backward through the entire navigation to reach the new page heading.

Screen reader users receive no indication that new content appeared on the page when content loads dynamically or components update. Closing modals returns focus to nowhere, leaving keyboard users disoriented about where they are in the document.

Deleting list items removes the focused element from the DOM, causing focus to jump to the browser address bar or nowhere. Expanding accordions or showing dropdown menus doesn’t move focus to the new content, forcing users to tab forward blindly hoping to find what appeared.

Without proper focus management, keyboard navigation becomes a frustrating maze where users lose their place constantly.

Solution

Programmatically move keyboard focus to appropriate elements as UI changes, especially when showing or hiding content, navigating between routes, or dynamically inserting new elements.

When opening modal dialogs, move focus to the dialog container or first focusable element inside. When closing modals, restore focus to the trigger element that opened the modal.

After route changes, move focus to the page heading or main content landmark so keyboard users start at the top of the new content. When deleting focused elements, move focus to the next logical element like the adjacent list item.

When showing new content like notifications or alerts, optionally move focus to them so screen readers announce their presence. Use element.focus() to programmatically set focus, and set tabindex="-1" on non-interactive elements to allow programmatic focus without adding them to the tab order.

This ensures keyboard users can navigate logically without getting lost or trapped in unexpected locations.

Example

This example demonstrates moving keyboard focus to a modal dialog when it opens so keyboard users can immediately interact with it.

// Web Component that manages focus when modal state changes
class Modal extends HTMLElement {
  constructor() {
    super();
    this.previouslyFocusedElement = null;
  }

  set open(value) {
    this._open = value;
    
    if (value) {
      // Save reference to element that had focus before opening modal
      this.previouslyFocusedElement = document.activeElement;
      
      this.render();
      
      // Programmatically move focus to modal dialog for keyboard users
      const dialog = this.querySelector('[role="dialog"]');
      dialog?.focus();
    } else {
      // Restore focus to element that opened the modal
      this.previouslyFocusedElement?.focus();
      this.previouslyFocusedElement = null;
    }
  }

  render() {
    if (this._open) {
      // tabindex="-1" allows programmatic focus but not tab-to-focus
      this.innerHTML = `
        <div role="dialog" aria-modal="true" tabindex="-1">
          <h2>Modal Title</h2>
          <p>Modal content</p>
          <button class="close">Close</button>
        </div>
      `;
      
      // Set up close button handler
      this.querySelector('.close').addEventListener('click', () => {
        this.open = false;
      });
    }
  }
}

customElements.define('modal-dialog', Modal);

React Modal with Focus Management

import { useEffect, useRef } from 'react';

function Modal({ isOpen, onClose, children }) {
  const dialogRef = useRef(null);
  const previousFocusRef = useRef(null);

  useEffect(() => {
    if (isOpen) {
      // Save previously focused element
      previousFocusRef.current = document.activeElement;
      
      // Move focus to dialog
      dialogRef.current?.focus();
    } else {
      // Restore focus when modal closes
      previousFocusRef.current?.focus();
    }
  }, [isOpen]);

  if (!isOpen) return null;

  return (
    <div
      role="dialog"
      aria-modal="true"
      ref={dialogRef}
      tabIndex={-1}
      className="modal"
    >
      <h2>Modal Title</h2>
      <div>{children}</div>
      <button onClick={onClose}>Close</button>
    </div>
  );
}

Route Change Focus Management

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

function App() {
  const location = useLocation();
  const mainContentRef = useRef(null);

  useEffect(() => {
    // Move focus to main content heading after route change
    mainContentRef.current?.focus();
  }, [location.pathname]);

  return (
    <div>
      <nav>
        <Link to="/">Home</Link>
        <Link to="/about">About</Link>
      </nav>
      
      <main>
        {/* tabindex="-1" allows focus but not tab navigation */}
        <h1 ref={mainContentRef} tabIndex={-1}>
          {getPageTitle(location.pathname)}
        </h1>
        <Routes>
          <Route path="/" element={<HomePage />} />
          <Route path="/about" element={<AboutPage />} />
        </Routes>
      </main>
    </div>
  );
}
<!-- Allow keyboard users to skip navigation -->
<a href="#main-content" class="skip-link">
  Skip to main content
</a>

<nav>
  <!-- Navigation links -->
</nav>

<main id="main-content" tabindex="-1">
  <!-- Main content -->
</main>

<style>
  .skip-link {
    position: absolute;
    top: -40px;
    left: 0;
    background: #000;
    color: white;
    padding: 8px;
    z-index: 100;
  }
  
  .skip-link:focus {
    top: 0;
  }
</style>

Notification Focus Management

function NotificationManager() {
  const notificationRef = useRef(null);

  const showNotification = (message) => {
    // Add notification to UI
    setNotifications(prev => [...prev, { id: Date.now(), message }]);
    
    // Move focus to notification for screen reader announcement
    // Use setTimeout to wait for DOM update
    setTimeout(() => {
      notificationRef.current?.focus();
    }, 0);
  };

  return (
    <div className="notifications">
      {notifications.map(notification => (
        <div
          key={notification.id}
          ref={notificationRef}
          role="alert"
          tabIndex={-1}
        >
          {notification.message}
        </div>
      ))}
    </div>
  );
}

List Item Deletion Focus Management

function TodoList({ items, onDelete }) {
  const listItemRefs = useRef({});

  const handleDelete = (id, index) => {
    onDelete(id);
    
    // Move focus to next item, or previous if deleting last item
    const nextIndex = index < items.length - 1 ? index : index - 1;
    const nextItem = items[nextIndex];
    
    if (nextItem) {
      // Wait for DOM update
      setTimeout(() => {
        listItemRefs.current[nextItem.id]?.focus();
      }, 0);
    }
  };

  return (
    <ul>
      {items.map((item, index) => (
        <li
          key={item.id}
          ref={el => listItemRefs.current[item.id] = el}
          tabIndex={-1}
        >
          {item.text}
          <button onClick={() => handleDelete(item.id, index)}>
            Delete
          </button>
        </li>
      ))}
    </ul>
  );
}

Accordion Focus Management

function Accordion({ items }) {
  const [expandedId, setExpandedId] = useState(null);
  const contentRefs = useRef({});

  const handleToggle = (id) => {
    const newExpandedId = expandedId === id ? null : id;
    setExpandedId(newExpandedId);
    
    // Move focus to expanded content
    if (newExpandedId) {
      setTimeout(() => {
        contentRefs.current[newExpandedId]?.focus();
      }, 0);
    }
  };

  return (
    <div>
      {items.map(item => (
        <div key={item.id}>
          <button
            aria-expanded={expandedId === item.id}
            aria-controls={`content-${item.id}`}
            onClick={() => handleToggle(item.id)}
          >
            {item.title}
          </button>
          
          {expandedId === item.id && (
            <div
              id={`content-${item.id}`}
              ref={el => contentRefs.current[item.id] = el}
              tabIndex={-1}
            >
              {item.content}
            </div>
          )}
        </div>
      ))}
    </div>
  );
}

Vue Focus Management

<script setup>
import { ref, watch, nextTick } from 'vue';

const isModalOpen = ref(false);
const modalRef = ref(null);
let previouslyFocusedElement = null;

const openModal = () => {
  previouslyFocusedElement = document.activeElement;
  isModalOpen.value = true;
};

const closeModal = () => {
  isModalOpen.value = false;
  previouslyFocusedElement?.focus();
};

watch(isModalOpen, async (isOpen) => {
  if (isOpen) {
    await nextTick();
    modalRef.value?.focus();
  }
});
</script>

<template>
  <button @click="openModal">Open Modal</button>
  
  <div
    v-if="isModalOpen"
    ref="modalRef"
    role="dialog"
    tabindex="-1"
  >
    <h2>Modal Title</h2>
    <button @click="closeModal">Close</button>
  </div>
</template>

Focus Visible Styling

/* Only show focus indicators for keyboard navigation */
button:focus {
  outline: none; /* Remove default outline */
}

button:focus-visible {
  outline: 2px solid blue;
  outline-offset: 2px;
}

/* Ensure focus indicators are always visible for keyboard users */
*:focus-visible {
  outline: 2px solid #0066cc;
  outline-offset: 2px;
}

Programmatic Focus Helper

function focusElement(selector, options = {}) {
  const { 
    preventScroll = false,
    delay = 0 
  } = options;
  
  const focusWithDelay = () => {
    const element = typeof selector === 'string' 
      ? document.querySelector(selector)
      : selector;
    
    if (element) {
      element.focus({ preventScroll });
      return true;
    }
    return false;
  };
  
  if (delay) {
    setTimeout(focusWithDelay, delay);
  } else {
    return focusWithDelay();
  }
}

// Usage
focusElement('#modal-dialog');
focusElement(buttonRef.current, { delay: 100 });

Focus Trap Prevention

function Dialog({ children, onClose }) {
  useEffect(() => {
    const handleTabKey = (e) => {
      if (e.key === 'Tab') {
        const focusableElements = dialog.querySelectorAll(
          'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
        );
        
        const firstElement = focusableElements[0];
        const lastElement = focusableElements[focusableElements.length - 1];
        
        // Trap focus within dialog
        if (e.shiftKey && document.activeElement === firstElement) {
          lastElement.focus();
          e.preventDefault();
        } else if (!e.shiftKey && document.activeElement === lastElement) {
          firstElement.focus();
          e.preventDefault();
        }
      }
    };
    
    document.addEventListener('keydown', handleTabKey);
    return () => document.removeEventListener('keydown', handleTabKey);
  }, []);
  
  return <div role="dialog">{children}</div>;
}

Benefits

  • Ensures keyboard users can navigate efficiently without tabbing through entire pages to reach new content or controls.
  • Required for WCAG 2.1 Level AA compliance under Success Criterion 2.4.3 (Focus Order) and essential for accessibility.
  • Prevents users from getting lost after UI changes like route transitions, modal opens/closes, or dynamic content insertion.
  • Improves experience for screen reader users by moving focus to new content so changes are announced immediately.
  • Essential for modal dialogs and dynamic content where focus management is the difference between usable and unusable interfaces.
  • Provides logical navigation flow that matches visual layout and user expectations.
  • Enables users to orient themselves quickly after interactions by placing focus in predictable locations.

Tradeoffs

  • Can be disorienting if focus jumps unexpectedly to locations users didn’t anticipate, interrupting their intended navigation flow.
  • Requires careful thought about where focus should go in each scenario - wrong decisions create worse experiences than no focus management.
  • Easy to get wrong and create unclear experiences where focus lands on non-interactive elements or skips over important content.
  • May interfere with user expectations if focus moves when users expected to continue from their current location.
  • Testing requires actual keyboard and screen reader usage across different browsers - automated tools cannot verify focus behavior feels natural.
  • Focus indicators must be carefully styled to be visible without being obtrusive - too subtle and keyboard users can’t see where they are, too prominent and it looks broken.
  • Different screen readers announce focus changes differently - NVDA, JAWS, and VoiceOver have varying behaviors that require testing across all platforms.
  • tabindex="-1" on headings or containers is necessary for programmatic focus but adds complexity to HTML and can confuse developers unfamiliar with the technique.
  • Focus restoration after modal close requires storing references to previously focused elements, adding state management complexity.
  • Asynchronous DOM updates require setTimeout or framework-specific delays (like nextTick in Vue) to focus elements that don’t exist yet, creating timing dependencies.
  • Browser differences in focus behavior - some browsers scroll focused elements into view automatically, others don’t, requiring scrollIntoView calls.
  • Focus management in single-page applications conflicts with browser history - focus shouldn’t move when users use back/forward buttons if content doesn’t change.
  • Overly aggressive focus management that moves focus on every small UI change creates a jarring experience where users constantly lose their place.
  • Screen reader virtual cursor vs focus distinction - screen reader users navigate with virtual cursor which may not match DOM focus, creating complexity in understanding user position.
  • Focus trapping in modals requires additional logic to prevent tab key from escaping the modal, adding implementation complexity beyond basic focus management.
Stay Updated

Get New Patterns
in Your Inbox

Join thousands of developers receiving regular insights on frontend architecture patterns

No spam. Unsubscribe anytime.