Skip to main content
Saved

Focus Management

Category Accessibility

Level Beginner

Examples

React, Vue

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

In this article
  1. Problem
  2. Solution
  3. Example
  4. Modal Focus Management
  5. Route Change Focus Management
  6. Skip Link
  7. List Item Deletion
  8. Focus Trap
  9. Focus Visible Styling
  10. Benefits
  11. Tradeoffs
  12. Summary
  13. See also

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, which is maddening.

When you delete a list item, the focused element is gone and focus often drops back to the document body, so the next Tab starts from the top of the page. When an accordion expands, focus stays on the trigger while new content appears below, and users have to Tab around hoping to find it.

Solution

When the UI changes in a way that affects what users should interact with next, focus should move to where it logically belongs.

Opening a modal moves focus into it, and closing it returns focus to the trigger element. Navigating to a new page in an SPA moves focus to the page heading, so screen reader users start at the new content rather than tabbing through navigation they’ve already passed.

Deleting a list item moves focus to the next item, or to the previous one if the deleted item was last. An important notification can take focus too, so screen readers announce the change.

element.focus() moves focus programmatically. Non-interactive elements like headings or containers need tabindex="-1", which makes them focusable from JavaScript without adding them to Tab navigation.

Before an overlay opens, the code stores a reference to the previously focused element, so it can put focus back there when the overlay closes.

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.innerHTML = '';
      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 { Routes, useLocation } from 'react-router-dom';
import { getPageTitle } from './routes';

// Rendered inside <BrowserRouter>, so useLocation() has a router to read from
function App() {
  const location = useLocation();
  const mainContentRef = useRef(null);
  const isFirstRender = useRef(true);

  useEffect(() => {
    // Leave focus alone on the initial page load; only move it on navigation
    if (isFirstRender.current) {
      isFirstRender.current = false;
      return;
    }
    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 an item is deleted, focus moves to the next item so users don’t lose their place:

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

  const handleDelete = (id, index) => {
    onDelete(id);
    // The item after the deleted one, or the one before if it was last
    const nextItem = items[index + 1] ?? items[index - 1];
    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.
  • WCAG 2.4.3 (Focus Order) compliance requires proper focus management.
  • 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

  • Moving focus to the wrong place, or moving it unexpectedly, can disorient users more than leaving it alone.
  • 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, so it’s worth testing in more than one.
  • Focus indicators need balancing: one faint enough to keep designers happy may be too faint for keyboard users to see.
  • 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. Focus moves deliberately to new content, goes back to the trigger when dialogs close, and lands where screen readers can announce the change. Thoughtful focus management makes SPAs feel as natural to keyboard users as traditional multi-page sites.

See also

Newsletter

A Monthly Email
from Den Odell

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

No spam. Unsubscribe anytime.