Managing focus in dynamic UIs

On a static page, focus takes care of itself: the user tabs from one control to the next and the browser keeps a clear record of where they are. The moment the interface starts changing under them — a panel is removed, a view is swapped in without a page load, a dialog opens and closes — that record can break. The focused element disappears, the new content is never reached, or the user is dumped back at the top of the document with no idea what just happened. Sighted mouse users rarely notice; for keyboard and screen reader users, a lost focus point is a lost place in the task.

This lesson works through the three focus failures behind most dynamic-UI complaints. The fix in every case is the same discipline: when you change what is on screen, decide deliberately where focus should land, and move it there with a real, programmatic .focus() call — never leave it to chance.

What you’ll learn

How to keep focus from being thrown to the top of the page when the element it was on is removed; how to move focus to the new view after a client-side route or tab change so the change is perceivable; and how to capture the triggering element and return focus to it when an overlay closes — including when the user dismisses it with Esc.

Standards this lesson maps to
Standard Criterion Level What it requires
WCAG 2.2 2.4.3 Focus Order A Focusable components receive focus in an order that preserves meaning and operability when content changes.
WCAG 2.2 2.1.2 No Keyboard Trap A Keyboard focus can move away from any component using only the keyboard; overlays must be dismissible.
WCAG 2.2 3.2.1 On Focus A Moving focus to an element does not trigger an unexpected change of context.
WCAG 2.2 4.1.3 Status Messages AA Where focus is not moved, a change of view or status is exposed to assistive tech via a live region.
EN 301 549 9.2.4.3 / 9.2.1.2 (incorporates WCAG) European harmonised standard; references the WCAG A/AA set including focus order and no keyboard trap.
Section 508 502.3 / 504 (incorporates WCAG A & AA) US federal ICT must meet WCAG 2.0 Level A and AA, including focus order and keyboard operation.
ADA Title II WCAG 2.1 AA (DOJ rule) AA US state/local government web content must conform to WCAG 2.1 AA.

The three problems we’ll fix

Each card below isolates one common focus-management defect. For every issue you get a plain-language statement of the problem, a Bad example (shown as escaped, non-running code so it can’t harm this page), a Good example, the copyable Code, and an ordered fix.

Focus lost when content is removed or replaced

WCAG 2.2 · 2.4.3 A EN 301 549 Section 508 ADA Title II

When the element that currently has focus is removed from the DOM — a “Delete” button on a row the user just deleted, an expanded panel that collapses, a notification that auto-dismisses — the browser has nowhere to keep focus, so it falls back to document.body. For a keyboard user that silently resets their position to the very top of the page; for a screen reader user the announcement stops and the next Tab starts again from the first control. The list they were working through is now far above them. The fix is to decide, before you remove the node, where focus should go next, and move it there.

Bad

The handler removes the row that contains the focused button and stops there. Focus has nowhere to go, so it drops to the body and the user is thrown to the top of the page.

bad-remove.js
deleteBtn.addEventListener('click', () => {
  const row = deleteBtn.closest('li');
  row.remove(); // focused button is gone — focus falls to <body>
});

Good

Before removing the row, the handler works out a sensible target — the next row, or the previous one, or the list’s heading if the list is now empty — and moves focus there. The user stays exactly where their task was.

good-remove.js
deleteBtn.addEventListener('click', () => {
  const row = deleteBtn.closest('li');
  const next = row.nextElementSibling || row.previousElementSibling;
  const target = next
    ? next.querySelector('button')
    : list.querySelector('h2'); // empty list: focus the heading
  row.remove();
  target.focus(); // move focus deliberately before the browser guesses
});

Code

A fallback container needs tabindex="-1" so it can receive programmatic focus. Read the focus target before you mutate the DOM, and guard against the element no longer existing.

focus-target.html
<!-- Container that can catch focus when no item remains -->
<section id="results" tabindex="-1">
  <h2>Saved items</h2>
  <ul><!-- items --></ul>
</section>

<script>
  // capture the fallback before the DOM changes
  const fallback = document.getElementById('results');
  // ...after removal, if nothing else is focusable:
  fallback.focus();
</script>

How to fix

  1. Before removing the focused element, choose where focus should land — usually the next sibling, the previous one, or the parent container.
  2. Compute that target before you call .remove(), so the reference is still valid.
  3. If the chosen target isn’t natively focusable (a heading or container), give it tabindex="-1" so .focus() works.
  4. Call .focus() immediately after the removal — don’t leave it to the browser, which will drop focus to the body.
  5. Verify with the keyboard only: delete an item and confirm the next Tab continues from a logical place, not the top of the page.

Route or view change doesn’t move focus to the new view

WCAG 2.2 · 2.4.3 A 4.1.3 AA EN 301 549 Section 508

In a single-page app, clicking a link swaps the main content without a full page load. The browser does the navigation a real page load would do for free — title update, focus reset to the document — for none of it. So the URL and the visible content change, but focus stays on the link the user just activated, which may now be in a different part of the page or gone entirely. A screen reader announces nothing; the user has no signal that the view changed. The fix is to move focus to the new view’s heading after each navigation, mirroring what a browser page load does.

Bad

The router renders the new view and updates the URL, but never touches focus. The change is silent for assistive technology and focus is stranded on the old link.

bad-route.js
function onRouteChange(view) {
  main.innerHTML = render(view);
  history.pushState({}, '', view.path);
  // focus never moves — screen reader users hear nothing
}

Good

After rendering, the router updates the document title and moves focus to the new view’s <h1> (made focusable with tabindex="-1"). The heading is read out, so the change is perceivable and the user starts at the top of the new content.

good-route.js
function onRouteChange(view) {
  main.innerHTML = render(view);
  history.pushState({}, '', view.path);
  document.title = view.title + ' — My App';

  const heading = main.querySelector('h1');
  heading.setAttribute('tabindex', '-1'); // make it focusable
  heading.focus(); // announces the new view, like a page load would
}

Code

If moving focus would be disruptive — for example a tab panel the user is scanning — don’t move it; announce the change in a polite live region instead, so 4.1.3 is met without stealing focus.

route-live-region.html
<!-- Persistent live region, announced without moving focus -->
<div id="route-status" role="status" aria-live="polite" class="visually-hidden"></div>

<script>
  function announce(text) {
    document.getElementById('route-status').textContent = text;
  }
  // after a non-disruptive view change:
  announce('Settings page loaded');
</script>

How to fix

  1. After each client-side navigation, render the view, then update document.title as a real page load would.
  2. Move focus to the new view’s main heading; give it tabindex="-1" so it can receive programmatic focus.
  3. Do the focus move after the new content is in the DOM, so the heading exists when you call .focus().
  4. Where moving focus would interrupt the user, announce the change in a role="status" live region instead (4.1.3).
  5. Test with a screen reader: activate a nav link and confirm the new view’s heading or status is announced.

Focus not returned to the trigger after closing an overlay

WCAG 2.2 · 2.4.3 A 2.1.2 A EN 301 549 Section 508 ADA Title II

A user opens a dialog, menu, or other overlay with a button, completes or cancels it, and the overlay closes. If nothing restores focus, it drops to the body and the user is back at the top of the page — having lost the button they were on and the surrounding context. The overlay must also be dismissible with Esc and never trap focus (2.1.2). The fix is to store the triggering element when the overlay opens and return focus to it on every close path, including the Esc key.

Bad

The dialog opens and closes but never records or restores the trigger. On close focus falls to the body, and there’s no Esc handler, so a keyboard user can be stranded inside it.

bad-dialog.js
openBtn.addEventListener('click', () => {
  dialog.hidden = false;
  dialog.querySelector('input').focus();
});
closeBtn.addEventListener('click', () => {
  dialog.hidden = true; // focus drops to <body>; no Esc, no return
});

Good

The trigger is stored on open and focus is returned to it on close. A single close() path handles the button, the backdrop, and Esc, so focus always comes back to where the user was.

good-dialog.js
let trigger = null;

function open() {
  trigger = document.activeElement; // remember who opened it
  dialog.hidden = false;
  dialog.querySelector('input').focus();
}

function close() {
  dialog.hidden = true;
  if (trigger) trigger.focus(); // return focus to the opener
}

dialog.addEventListener('keydown', (e) => {
  if (e.key === 'Escape') close(); // dismissible — no keyboard trap
});

Code

The native <dialog> element with showModal() handles the focus trap and Esc for you, but you still restore the trigger on its close event. Storing activeElement keeps this robust even when several controls can open the same dialog.

native-dialog.js
const dialog = document.querySelector('dialog');
let trigger = null;

openBtn.addEventListener('click', () => {
  trigger = openBtn;
  dialog.showModal(); // traps focus + enables Esc natively
});

// fires for the close button, Esc, or form method="dialog"
dialog.addEventListener('close', () => {
  if (trigger) trigger.focus();
});

How to fix

  1. When the overlay opens, store the triggering element — usually document.activeElement — in a variable.
  2. Route every close path (button, backdrop click, Esc) through one close() function so focus is always restored.
  3. In that function, call .focus() on the stored trigger after hiding the overlay.
  4. Make sure Esc dismisses the overlay and focus can leave it — never trap the keyboard (2.1.2).
  5. Prefer the native <dialog> with showModal(), which handles the trap and Esc; you still restore the trigger on its close event.

Recap

  • When you remove or replace the element that has focus, move focus to a sensible nearby place — a heading, the next item, or the container — so the user isn’t thrown to the top of the page (2.4.3).
  • On a client-side route, tab, or view change, send focus to the new view’s heading (made focusable with tabindex="-1") so the change is perceivable; if you can’t move focus, announce it in a live region (2.4.3, 4.1.3).
  • Before opening an overlay, store the trigger; when it closes — including on Esc — return focus to that stored element, and make sure focus can always leave (2.4.3, 2.1.2).

The same discipline satisfies WCAG, EN 301 549, Section 508, and ADA Title II at once: every time the UI changes, decide where focus goes and put it there.