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.3AEN 301 549Section 508ADA 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
Before removing the focused element, choose where focus should land — usually
the next sibling, the previous one, or the parent container.
Compute that target before you call .remove(), so the
reference is still valid.
If the chosen target isn’t natively focusable (a heading or container), give
it tabindex="-1" so .focus() works.
Call .focus() immediately after the removal — don’t leave it to
the browser, which will drop focus to the body.
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.3A4.1.3AAEN 301 549Section 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
After each client-side navigation, render the view, then update
document.title as a real page load would.
Move focus to the new view’s main heading; give it
tabindex="-1" so it can receive programmatic focus.
Do the focus move after the new content is in the DOM, so the heading exists
when you call .focus().
Where moving focus would interrupt the user, announce the change in a
role="status" live region instead (4.1.3).
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.3A2.1.2AEN 301 549Section 508ADA 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
When the overlay opens, store the triggering element — usually
document.activeElement — in a variable.
Route every close path (button, backdrop click, Esc) through one
close() function so focus is always restored.
In that function, call .focus() on the stored trigger after
hiding the overlay.
Make sure Esc dismisses the overlay and focus can leave it — never
trap the keyboard (2.1.2).
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.