Focus Management

Difficulty: Intermediate

Focus management is the practice of controlling which element has keyboard focus at any given time and ensuring that focus moves logically and predictably as users interact with your page. Poor focus management is one of the most common accessibility failures in modern web applications, particularly those with dynamic content, modals, tooltips, and single-page navigation.

The :focus-visible pseudo-class is a modern CSS feature that applies focus styles only when the browser determines the user is navigating with a keyboard (or another non-pointer device). Unlike :focus, which triggers on every focus event including mouse clicks, :focus-visible intelligently distinguishes between mouse-initiated focus (where outlines feel unnecessary) and keyboard-initiated focus (where outlines are essential). This solves the longstanding tension between designers who want to remove focus outlines and accessibility requirements that mandate visible focus indicators.

Focus trapping is the technique of constraining Tab and Shift+Tab to cycle only through the focusable elements within a specific container, such as a modal dialog. When a modal opens, focus should move to the first focusable element inside it (or the dialog container itself). While the modal is open, pressing Tab at the last focusable element should wrap to the first, and Shift+Tab at the first should wrap to the last. The user must be able to close the modal with the Escape key at any time. Modern HTML provides the <dialog> element with built-in focus trapping via the showModal() method.

Focus restoration is equally important: when a modal, dropdown, or transient UI component closes, focus must return to the element that triggered it. Without this, keyboard users lose their place on the page and have to Tab from the beginning to find where they were. The pattern involves storing a reference to document.activeElement before opening the component, and calling .focus() on that stored reference when the component closes.

In single-page applications, route changes create a unique focus challenge. When a user navigates to a new "page" that is actually dynamically rendered content, focus typically stays on the link or button they clicked - meaning new content loads below or around them without any indication. The solution is to programmatically move focus to the new page's heading or main content area after the route transition. This mimics the natural behavior of a full page load, where the browser resets focus to the top of the document.

Code examples

:focus vs :focus-visible

<style>
  /* :focus applies on ALL focus events (mouse + keyboard) */
  .button-focus:focus {
    outline: 3px solid blue;
  }

  /* :focus-visible applies only on keyboard navigation */
  .button-visible:focus-visible {
    outline: 3px solid blue;
    outline-offset: 2px;
  }

  /* Common pattern: Remove default, add :focus-visible */
  .modern-button:focus {
    outline: none; /* Remove default for mouse clicks */
  }
  .modern-button:focus-visible {
    outline: 2px solid #005fcc;
    outline-offset: 2px;
    border-radius: 4px;
  }

  /* Global focus-visible styles for all interactive elements */
  :focus-visible {
    outline: 2px solid #005fcc;
    outline-offset: 2px;
  }
</style>

<button class="button-focus">Always shows outline</button>
<button class="button-visible">Outline only on keyboard</button>
<button class="modern-button">Modern approach</button>

:focus-visible lets you provide visible focus indicators for keyboard users without showing outlines on mouse clicks. This is now supported in all modern browsers and is the recommended approach for focus styling.

Focus Trapping in a Modal Dialog

<!-- Using native <dialog> with built-in focus trapping -->
<button id="open-btn">Open Settings</button>

<dialog id="settings-dialog" aria-labelledby="dialog-title">
  <h2 id="dialog-title">Settings</h2>

  <label for="theme">Theme</label>
  <select id="theme">
    <option>Light</option>
    <option>Dark</option>
  </select>

  <label for="font-size">Font Size</label>
  <input id="font-size" type="number" value="16" />

  <div>
    <button id="save-btn">Save</button>
    <button id="cancel-btn">Cancel</button>
  </div>
</dialog>

<script>
const openBtn = document.getElementById('open-btn');
const dialog = document.getElementById('settings-dialog');
const cancelBtn = document.getElementById('cancel-btn');
const saveBtn = document.getElementById('save-btn');

// showModal() provides built-in focus trapping and backdrop
openBtn.addEventListener('click', () => {
  dialog.showModal(); // Focus moves into dialog automatically
});

// Close and restore focus to trigger
cancelBtn.addEventListener('click', () => {
  dialog.close();
  openBtn.focus(); // Restore focus
});

saveBtn.addEventListener('click', () => {
  // Save settings logic...
  dialog.close();
  openBtn.focus(); // Restore focus
});

// Handle Escape key (built-in with <dialog>)
dialog.addEventListener('close', () => {
  openBtn.focus(); // Restore focus on any close
});
</script>

The native <dialog> element with showModal() provides built-in focus trapping - Tab cycles within the dialog, a backdrop prevents interaction with the page behind it, and Escape closes it. Always restore focus to the triggering element on close.

Manual Focus Trap for Custom Modals

<div id="custom-modal" role="dialog" aria-modal="true" aria-labelledby="modal-heading">
  <h2 id="modal-heading">Confirm Action</h2>
  <p>Are you sure you want to delete this item?</p>
  <button id="confirm">Delete</button>
  <button id="cancel">Cancel</button>
</div>

<script>
function trapFocus(element) {
  const focusableSelectors = [
    'a[href]', 'button:not([disabled])',
    'input:not([disabled])', 'select:not([disabled])',
    'textarea:not([disabled])', '[tabindex]:not([tabindex="-1"])'
  ].join(', ');

  const focusable = element.querySelectorAll(focusableSelectors);
  const firstFocusable = focusable[0];
  const lastFocusable = focusable[focusable.length - 1];

  element.addEventListener('keydown', (e) => {
    if (e.key !== 'Tab') return;

    if (e.shiftKey) {
      // Shift+Tab: wrap from first to last
      if (document.activeElement === firstFocusable) {
        e.preventDefault();
        lastFocusable.focus();
      }
    } else {
      // Tab: wrap from last to first
      if (document.activeElement === lastFocusable) {
        e.preventDefault();
        firstFocusable.focus();
      }
    }
  });

  // Focus the first element
  firstFocusable.focus();
}

// Usage
const modal = document.getElementById('custom-modal');
trapFocus(modal);
</script>

When you cannot use <dialog>, implement focus trapping manually. Query all focusable elements, intercept Tab and Shift+Tab at the boundaries, and wrap focus to create a cycle. Remember to also handle Escape to close the modal.

Focus Management in SPA Route Changes

<!-- Pattern for focus management on route change -->
<nav>
  <a href="/home" data-route>Home</a>
  <a href="/about" data-route>About</a>
  <a href="/contact" data-route>Contact</a>
</nav>

<main id="app-main">
  <h1 id="page-title" tabindex="-1">About Us</h1>
  <p>Content loads dynamically here...</p>
</main>

<script>
// After route change and new content renders:
function onRouteChange() {
  // Wait for DOM update
  requestAnimationFrame(() => {
    const heading = document.getElementById('page-title');
    if (heading) {
      heading.focus(); // Move focus to new page heading
    }

    // Also update document title for screen readers
    document.title = `About Us - My App`;
  });
}

// Announce route change to screen readers
function announceNavigation(pageName) {
  const announcer = document.getElementById('route-announcer');
  announcer.textContent = `Navigated to ${pageName}`;
}
</script>

<!-- Hidden live region for route announcements -->
<div
  id="route-announcer"
  role="status"
  aria-live="polite"
  style="position: absolute; width: 1px; height: 1px; overflow: hidden;"
></div>

In SPAs, move focus to the main heading after route transitions so keyboard and screen reader users know the page changed. The heading needs tabindex='-1' to receive programmatic focus. A live region can also announce the navigation for screen readers.

Key points

Concepts covered

:focus-visible, Focus Trapping, Focus Restoration, Modal Focus, Programmatic Focus