Skip to main content

Fix Focus Management in Dynamic Web Applications

Focus management determines where keyboard focus goes when the page changes. When focus is lost or misplaced, keyboard and screen reader users cannot continue navigating. This guide covers the most common focus problems in dynamic web apps and how to fix each one.

Relevant WCAG Success Criteria

  • 2.4.3 Focus Order (Level A) — navigable components receive focus in an order that preserves meaning and operability.
  • 2.4.7 Focus Visible (Level AA) — any keyboard-operable interface has a visible focus indicator.
  • 3.2.1 On Focus (Level A) — receiving focus does not trigger an unexpected change of context.

Problem 1: Focus Lost After Content Removal

When a focused element is removed from the DOM — a deleted list item, a closed modal, a dismissed notification — the browser resets focus to the <body>. Keyboard users lose their place entirely.

Before (broken):

// Deleting an item — focus disappears
deleteButton.addEventListener('click', () => {
  const item = deleteButton.closest('.list-item');
  item.remove(); // focus goes to <body>
});

After (fixed):

// Move focus to a logical target before removing the element
deleteButton.addEventListener('click', () => {
  const item = deleteButton.closest('.list-item');
  const nextItem = item.nextElementSibling || item.previousElementSibling;
  const fallback = item.closest('.list-container');

  item.remove();

  const target = nextItem?.querySelector('.delete-btn') || fallback;
  if (target) {
    target.setAttribute('tabindex', '-1');
    target.focus();
  }
});

The key pattern: identify a logical focus target before removing the element, then move focus there after removal.

Problem 2: Modal Opens Without Receiving Focus

When a modal dialog opens but focus stays behind it, keyboard users cannot reach the modal content. They tab through background elements instead.

Before (broken):

// Modal opens but focus stays on the trigger button
function openModal() {
  modal.classList.remove('hidden');
  modal.setAttribute('aria-hidden', 'false');
}

After (fixed):

function openModal(triggerElement) {
  modal.classList.remove('hidden');
  modal.setAttribute('aria-hidden', 'false');

  // Store the trigger so we can return focus on close
  modal._triggerElement = triggerElement;

  // Use inert to disable background content
  document.querySelector('#app-root').inert = true;

  // Move focus into the modal after render
  requestAnimationFrame(() => {
    const firstFocusable = modal.querySelector(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    if (firstFocusable) {
      firstFocusable.focus();
    } else {
      modal.setAttribute('tabindex', '-1');
      modal.focus();
    }
  });
}

function closeModal() {
  const trigger = modal._triggerElement;

  document.querySelector('#app-root').inert = false;
  modal.classList.add('hidden');
  modal.setAttribute('aria-hidden', 'true');

  // Return focus to the element that opened the modal
  if (trigger && document.contains(trigger)) {
    trigger.focus();
  }
}

Problem 3: Focus Not Trapped in Modal

Even when focus moves into a modal, users can tab out of it into background content. Use a focus trap to keep focus cycling within the modal.

function trapFocus(container) {
  const focusable = container.querySelectorAll(
    'a[href], button:not([disabled]), input:not([disabled]), ' +
    'select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
  );
  const first = focusable[0];
  const last = focusable[focusable.length - 1];

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

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

A simpler modern alternative is the inert attribute. Setting inert on all content outside the modal prevents focus from escaping without manual trap logic.

// Modern approach: inert attribute
function openModal() {
  modal.showModal(); // native <dialog> traps focus automatically
}

// Or with inert for custom modals:
document.querySelector('main').inert = true;
document.querySelector('header').inert = true;
modal.hidden = false;
modal.querySelector('[autofocus]')?.focus();

Problem 4: SPA Route Changes Lose Focus

In single-page applications, navigating between routes does not trigger a full page load. The browser does not reset focus or announce the new page. Screen reader users have no idea the page changed.

Before (broken):

// React Router — route changes silently
function App() {
  return (
    <Routes>
      <Route path="/dashboard" element={<Dashboard />} />
      <Route path="/settings" element={<Settings />} />
    </Routes>
  );
}

After (fixed):

// Move focus to the new page heading and announce it
function RouteAnnouncer() {
  const location = useLocation();
  const headingRef = useRef(null);

  useEffect(() => {
    // Wait for content to render
    requestAnimationFrame(() => {
      const h1 = document.querySelector('h1');
      if (h1) {
        h1.setAttribute('tabindex', '-1');
        h1.focus({ preventScroll: false });
      }
    });
  }, [location.pathname]);

  return (
    <div
      role="status"
      aria-live="polite"
      aria-atomic="true"
      className="sr-only"
    >
      {document.title}
    </div>
  );
}

Problem 5: Focus Order Does Not Match Visual Order

CSS properties like order, flex-direction: row-reverse, and position: absolute can make the visual order differ from the DOM order. Since focus follows DOM order, keyboard users encounter elements in a confusing sequence.

Before (broken):

<!-- DOM order: Cancel first, Submit second -->
<!-- Visual order (via CSS flex-direction: row-reverse): Submit first, Cancel second -->
<div style="display: flex; flex-direction: row-reverse;">
  <button>Cancel</button>
  <button>Submit</button>
</div>

After (fixed):

<!-- Match DOM order to visual order -->
<div style="display: flex;">
  <button>Submit</button>
  <button>Cancel</button>
</div>

The rule is straightforward: restructure the HTML so DOM order matches visual order. Avoid using CSS order, flex-direction: row-reverse, or absolute positioning to rearrange interactive elements.

Problem 6: Inline Error Messages Not Announced

When a form is submitted and validation errors appear, focus should move to the first error so users know something went wrong.

function handleSubmit(form) {
  const errors = validateForm(form);

  if (errors.length > 0) {
    // Show error summary
    const summary = document.getElementById('error-summary');
    summary.innerHTML = '<h3>Please fix the following errors:</h3>';
    const list = document.createElement('ul');

    errors.forEach(err => {
      const li = document.createElement('li');
      const link = document.createElement('a');
      link.href = '#' + err.fieldId;
      link.textContent = err.message;
      li.appendChild(link);
      list.appendChild(li);
    });

    summary.appendChild(list);
    summary.hidden = false;

    // Move focus to the summary
    summary.setAttribute('tabindex', '-1');
    summary.focus();
    return;
  }

  // Submit form...
  submitForm(form).then(() => {
    // After successful submission, move focus to confirmation
    const confirmation = document.getElementById('success-message');
    confirmation.hidden = false;
    confirmation.setAttribute('tabindex', '-1');
    confirmation.focus();
  });
}

Problem 7: Focus Visible Not Styled

Browsers provide default focus outlines, but many codebases remove them with outline: none without providing a replacement. This makes focus invisible for keyboard users.

Before (broken):

/* Common CSS reset — removes all focus indicators */
*:focus {
  outline: none;
}

After (fixed):

/* Remove outline only for mouse clicks, keep for keyboard */
*:focus {
  outline: none;
}
*:focus-visible {
  outline: 2px solid #4f46e5;
  outline-offset: 2px;
}

/* For custom components, add a visible ring */
button:focus-visible,
a:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
  outline: 2px solid #4f46e5;
  outline-offset: 2px;
  border-radius: 2px;
}

The :focus-visible pseudo-class applies only when the browser determines focus should be visible — typically keyboard navigation, not mouse clicks. This gives you the best of both worlds.

Problem 8: Focus in Accordion and Tab Panels

When a user activates a tab or expands an accordion, the associated panel content should become reachable. For tabs, the panel should be the next tab stop. For accordions, focus can stay on the trigger and the content simply expands below it.

// Tab panel: move focus to the panel on activation
tabButton.addEventListener('click', () => {
  // Deactivate all tabs
  tabs.forEach(t => {
    t.setAttribute('aria-selected', 'false');
    t.setAttribute('tabindex', '-1');
  });
  // Activate this tab
  tabButton.setAttribute('aria-selected', 'true');
  tabButton.setAttribute('tabindex', '0');

  // Show the associated panel
  const panel = document.getElementById(tabButton.getAttribute('aria-controls'));
  panels.forEach(p => p.hidden = true);
  panel.hidden = false;

  // Move focus to the panel
  panel.setAttribute('tabindex', '-1');
  panel.focus();
});

// Arrow key navigation between tabs
tabList.addEventListener('keydown', (e) => {
  const currentIndex = tabs.indexOf(document.activeElement);
  let newIndex;

  if (e.key === 'ArrowRight') {
    newIndex = (currentIndex + 1) % tabs.length;
  } else if (e.key === 'ArrowLeft') {
    newIndex = (currentIndex - 1 + tabs.length) % tabs.length;
  } else {
    return;
  }

  e.preventDefault();
  tabs[newIndex].focus();
});

Problem 9: Infinite Scroll Breaks Focus

When new content loads at the bottom of a list, focus should not jump. The user should continue tabbing from where they are. The common mistake is replacing the entire list DOM, which resets focus.

// Append new items instead of replacing the list
async function loadMore() {
  const items = await fetchNextPage();
  const list = document.querySelector('.results-list');

  // Save the last item before appending
  const lastExisting = list.lastElementChild;

  items.forEach(item => {
    const el = createItemElement(item);
    list.appendChild(el); // append, don't replace
  });

  // Announce to screen readers
  const liveRegion = document.getElementById('results-status');
  liveRegion.textContent = `${items.length} more results loaded. ${list.children.length} total.`;
}

Using tabindex="-1" for Programmatic Focus

Most non-interactive elements like <div>, <h1>, and <section> are not focusable by default. To move focus to them programmatically, add tabindex="-1". This makes them focusable via JavaScript but keeps them out of the normal tab order.

// Focus a heading after navigation
const heading = document.querySelector('h1');
heading.setAttribute('tabindex', '-1');
heading.focus();

// The user can then press Tab to move to the first interactive
// element after the heading — natural reading order is preserved.

Never use tabindex values greater than 0. They override the natural DOM order and create unpredictable focus sequences.

Timing: Why requestAnimationFrame Matters

Calling .focus() immediately after a DOM change can fail if the browser has not yet rendered the new element. Wrapping the call in requestAnimationFrame ensures the element is in the layout before focusing it.

// May fail — element not rendered yet
modal.hidden = false;
modal.querySelector('input').focus(); // might not work

// Reliable — waits for render
modal.hidden = false;
requestAnimationFrame(() => {
  modal.querySelector('input').focus();
});

In some frameworks, you may need a double requestAnimationFrame or the framework's own scheduling mechanism (like Vue's nextTick or React's useEffect).

React Patterns

import { useRef, useEffect } from 'react';

// useRef + useEffect for focus after mount
function Modal({ isOpen, onClose }) {
  const closeRef = useRef(null);
  const triggerRef = useRef(null);

  useEffect(() => {
    if (isOpen) {
      closeRef.current?.focus();
    }
  }, [isOpen]);

  return isOpen ? (
    <div role="dialog" aria-modal="true">
      <button ref={closeRef} onClick={onClose}>Close</button>
      {/* modal content */}
    </div>
  ) : null;
}

// Focus after delete in a list
function ItemList({ items, onDelete }) {
  const listRef = useRef(null);
  const deletedIndex = useRef(null);

  useEffect(() => {
    if (deletedIndex.current !== null) {
      const buttons = listRef.current.querySelectorAll('button');
      const target = buttons[Math.min(deletedIndex.current, buttons.length - 1)];
      target?.focus();
      deletedIndex.current = null;
    }
  }, [items]);

  return (
    <ul ref={listRef}>
      {items.map((item, i) => (
        <li key={item.id}>
          {item.name}
          <button onClick={() => {
            deletedIndex.current = i;
            onDelete(item.id);
          }}>Delete</button>
        </li>
      ))}
    </ul>
  );
}

Vue Patterns

<template>
  <div>
    <dialog ref="dialogRef" @close="onClose">
      <button ref="closeBtn" @click="close">Close</button>
      <!-- content -->
    </dialog>
  </div>
</template>

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

const dialogRef = ref(null);
const closeBtn = ref(null);
const triggerEl = ref(null);

async function open(trigger) {
  triggerEl.value = trigger;
  dialogRef.value.showModal();
  await nextTick();
  closeBtn.value?.focus();
}

function close() {
  dialogRef.value.close();
}

function onClose() {
  triggerEl.value?.focus();
}
</script>

Angular Patterns

import { Component, ElementRef, ViewChild, AfterViewInit } from '@angular/core';

@Component({
  selector: 'app-modal',
  template: `
    <div *ngIf="isOpen" role="dialog" aria-modal="true">
      <button #closeBtn (click)="close()">Close</button>
    </div>
  `
})
export class ModalComponent implements AfterViewInit {
  @ViewChild('closeBtn') closeBtn!: ElementRef;
  isOpen = false;
  private triggerElement: HTMLElement | null = null;

  open(trigger: HTMLElement) {
    this.triggerElement = trigger;
    this.isOpen = true;

    // Angular needs a tick to render the template
    setTimeout(() => {
      this.closeBtn?.nativeElement.focus();
    });
  }

  close() {
    this.isOpen = false;
    this.triggerElement?.focus();
  }
}

Testing Focus Management

You can verify correct focus management without any special tools:

  • Unplug your mouse and navigate with Tab, Shift+Tab, Enter, and Escape.
  • Open a modal — does focus move inside it? Can you tab through the content without escaping?
  • Close the modal — does focus return to the trigger button?
  • Delete an item from a list — can you continue tabbing through the remaining items?
  • Navigate between pages in a SPA — does focus move to the new page heading?
  • Use document.activeElement in the browser console to check what currently has focus.

Summary

Every dynamic DOM change should answer the question: where does focus go next? The rules are consistent across all frameworks:

  • When content is removed, move focus to the nearest logical element.
  • When a modal opens, move focus inside and trap it. When it closes, return focus to the trigger.
  • When a route changes, move focus to the new page heading and announce the change.
  • When errors appear, move focus to the error summary.
  • Never remove :focus-visible styles without providing an alternative.
  • Use tabindex="-1" for programmatic focus targets, never tabindex values above 0.
  • Use requestAnimationFrame or framework scheduling to time .focus() calls after render.
  • Use the inert attribute to disable background content behind modals.

Er nettstedet ditt tilgjengelig?

Skann nettstedet ditt gratis og få WCAG-poengsummen din på noen få minutter.

Skann nettstedet ditt gratis