Skip to main content
Saved
Pattern
Difficulty Beginner

Focus Management

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

By Den Odell Added

Focus Management

Problem

A user opens a modal dialog, but focus stays on the button behind the overlay. When they press Tab expecting to reach the modal’s close button, they instead cycle through the entire page beneath it, completely lost.

I’ve tested applications where navigating to a new page left focus stuck on the clicked navigation link, forcing keyboard users to Tab through thirty elements just to reach the new content. Maddening.

When you delete a list item, focus often vanishes into the void or jumps to the address bar. Expand an accordion? Focus stays on the trigger while new content appears below, leaving users to Tab blindly hoping to find it.

Solution

The core principle: when the UI changes in a way that affects what users should interact with next, move focus to where it logically belongs.

Open a modal? Move focus into it. Close it? Return focus to the trigger element. Navigate to a new page in an SPA? Focus the page heading so screen reader users start at the new content rather than tabbing through navigation they’ve already passed.

Delete a list item? Move focus to the next item, or the previous if deleting the last. Show an important notification? Consider focusing it so screen readers announce the change.

Call element.focus() to move focus programmatically. For non-interactive elements like headings or containers, add tabindex="-1" to make them focusable via JavaScript without adding them to Tab navigation.

Store a reference to the previously focused element before opening overlays. You’ll need it to restore focus when they close.

Example

class Modal extends HTMLElement {
  constructor() {
    super();
    this.previouslyFocusedElement = null;
  }

  set open(value) {
    this._open = value;
    if (value) {
      this.previouslyFocusedElement = document.activeElement;
      this.render();
      this.querySelector('[role="dialog"]')?.focus();
    } else {
      this.previouslyFocusedElement?.focus();
      this.previouslyFocusedElement = null;
    }
  }

  render() {
    if (this._open) {
      this.innerHTML = `
        <div role="dialog" aria-modal="true" tabindex="-1">
          <h2>Modal Title</h2>
          <button class="close">Close</button>
        </div>
      `;
      this.querySelector('.close').addEventListener('click', () => {
        this.open = false;
      });
    }
  }
}
customElements.define('modal-dialog', Modal);

Route Change Focus Management

Moving focus to the main heading after navigation helps screen reader users find new content immediately:

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

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

  useEffect(() => {
    mainContentRef.current?.focus();
  }, [location.pathname]);

  return (
    <main>
      <h1 ref={mainContentRef} tabIndex={-1}>
        {getPageTitle(location.pathname)}
      </h1>
      <Routes>{/* routes */}</Routes>
    </main>
  );
}

Skip links let keyboard users bypass repetitive navigation:

<a href="#main-content" class="skip-link">Skip to main content</a>
<nav><!-- Navigation --></nav>
<main id="main-content" tabindex="-1"><!-- Content --></main>

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

List Item Deletion

When deleting items, move focus to the next item so users don’t lose their place:

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

  const handleDelete = (id, index) => {
    onDelete(id);
    const nextIndex = index < items.length - 1 ? index : index - 1;
    const nextItem = items[nextIndex];
    if (nextItem) {
      setTimeout(() => itemRefs.current[nextItem.id]?.focus(), 0);
    }
  };

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

Focus Trap

Trapping focus within modals prevents keyboard users from interacting with content behind overlays:

function Dialog({ children }) {
  const dialogRef = useRef(null);

  useEffect(() => {
    const handleTab = (e) => {
      if (e.key !== 'Tab') return;

      const focusable = dialogRef.current.querySelectorAll(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      );
      const first = focusable[0];
      const last = focusable[focusable.length - 1];

      if (e.shiftKey && document.activeElement === first) {
        last.focus();
        e.preventDefault();
      } else if (!e.shiftKey && document.activeElement === last) {
        first.focus();
        e.preventDefault();
      }
    };

    document.addEventListener('keydown', handleTab);
    return () => document.removeEventListener('keydown', handleTab);
  }, []);

  return <div role="dialog" ref={dialogRef}>{children}</div>;
}

Focus Visible Styling

Using :focus-visible shows focus outlines only for keyboard navigation:

*:focus-visible {
  outline: 2px solid #0066cc;
  outline-offset: 2px;
}

Benefits

  • Keyboard users stay oriented as focus moves logically with UI changes. They always know where they are.
  • WCAG 2.4.3 (Focus Order) compliance requires proper focus management; it’s not optional for accessible applications.
  • Screen readers announce content when focus moves to it, so users know when things change.
  • Modal dialogs become usable for keyboard users who otherwise can’t reach overlay content.
  • Consistent patterns build muscle memory, making your application feel natural over time.

Tradeoffs

  • Incorrect focus placement is worse than none at all. Moving focus unexpectedly disorients users.
  • Determining the “right” focus target for each scenario requires careful thought; it’s not always obvious.
  • Async DOM updates complicate things: you can’t focus elements that don’t exist yet, so you’ll need setTimeout or framework utilities like nextTick.
  • tabindex="-1" on headings confuses developers unfamiliar with the pattern, but it’s needed for programmatic focus.
  • Screen readers (NVDA, JAWS, VoiceOver) announce focus changes differently. Test across multiple.
  • Focus indicators require balance: too subtle fails users, too prominent draws designer complaints.
  • Browser back/forward navigation shouldn’t move focus if content hasn’t meaningfully changed, but many implementations miss this.
  • Overly aggressive focus management is jarring; moving focus on every small change makes users lose their place.

Summary

Focus management ensures keyboard focus moves logically during dynamic changes: route navigation, modal openings, and content updates. Move focus intentionally to new content, restore it when dialogs close, and announce changes to screen readers. Thoughtful focus management makes SPAs feel as natural to keyboard users as traditional multi-page sites.

Newsletter

A Monthly Email
from Den Odell

Behind-the-scenes thinking on frontend patterns, site updates, and more

No spam. Unsubscribe anytime.