Accessibility · Keyboard and Focus

Restoring Focus After the Focused Element Disappears

When a focused node is removed the browser falls back to the body, and the next Tab press restarts at the top of the page. How to choose a restoration target from structure rather than from memory.

Deleting the focused element is the single most reliable way to lose a keyboard user’s place. The browser has a defined response to it, the response is not what authors expect, and the resulting symptom — Tab restarting at the top of the page — reads as a focus-order bug rather than as the consequence of a removal.

What the browser does when focus is removed

Removal is handled by a defined step in the removal procedure for nodes: if the removed node is the document’s focused area, the document’s focused area is set to the document’s viewport. The step is explicit that it does not run the unfocusing or focusing steps, so no blur, focusout, focus, or focusin event fires. A component that watches for blur on the element it is about to delete therefore sees nothing at all, and neither does one watching focusout on a container.

The observable result is that document.activeElement returns the body element, because the accessibility-facing getter returns the body when the focused area is the document itself. Scripts that read activeElement see the body; scripts that wait for an event see silence.

This is a fallback, not a restoration. The browser has no notion of the element’s former position in the tree, because the element is gone, and the specification defines nothing about where the subsequent tab walk resumes. The starting point may or may not have been set before the removal, and nothing about a removal is specified to move it, so its value at that moment is whatever the last qualifying interaction left behind. The consequence is that the tab sequence after a removal is user-agent territory: it may resume near where the element was, or it may not, and there is no contract to design against.

Becoming unfocusable is a different case with a different mechanism, and the difference is visible from a handler. The focus fixup rule runs as part of the event loop’s processing: for each document whose focused area is no longer a focusable area, the user agent runs the focusing steps for the document’s viewport. Setting hidden on a focused element, or setting disabled on a focused control, triggers that rule, and unlike removal it does run the unfocusing steps — so blur fires. Removing the element fires nothing.

That asymmetry is easy to miss and it matters for ordering. A component that deletes a focused row inside a blur handler never runs the handler, because no blur is dispatched; a component that hides the row instead does run it, with relatedTarget null, and can act on the event. Both end with document.activeElement reporting the body.

That uncertainty is the argument for not relying on it. Even in the most favourable case, the user has no visible indication of where they are, and any script that inspects document.activeElement sees the body rather than the neighbourhood. A restoration that works has to be explicit, which means choosing a target before the DOM changes.

Removing an item from a list

The canonical case is a list of rows, each with a delete button. The user tabs to the third row’s button, presses Enter, the row is removed, and focus is now on the body. The user’s next Tab press lands at the top of the page.

The correct target is structural: the next row’s delete button if one exists, otherwise the previous row’s, otherwise the list container itself. That ordering — forward first, backward second, container third — matches what the user was doing and keeps repeated deletions cheap, since the user can delete several rows in a row without leaving the list.

function removeRow(row, list) {
  const rows = [...list.querySelectorAll('[data-row]')];
  const index = rows.indexOf(row);
  const next = rows[index + 1] ?? rows[index - 1] ?? null;

  row.remove();

  if (next) {
    next.querySelector('button')?.focus();
  } else {
    list.focus(); // the list carries tabindex="-1" so it can receive focus when it empties
  }
}

Two details make this robust. The next reference is captured before the removal, because after row.remove() the sibling relationship is gone and the index is meaningless. And the list carries tabindex="-1", so the empty case has somewhere to put focus that is not the body — a focusable container with an accessible name, so a screen reader announces the region rather than nothing.

Capturing before removal also protects against the case where the removal is asynchronous. A list that animates a row out, or that waits for a server response before splicing, must capture the neighbour at the start of the operation and refocus at the end; the element may have moved or been re-rendered in between.

Re-rendering a subtree

Framework-driven rendering replaces nodes rather than mutating them, so the element that held focus is destroyed even when the visual result is identical. A filter that re-renders a results list, a sort that reorders rows, a form section that re-renders after validation: in each case the focused element is a new node, and focus is on the body.

The restoration target here is identified by identity of the underlying item, not by node. The pattern is to record a stable key before the render and re-find the corresponding element after it:

// Record the semantic position, not the node.
function captureFocus(container) {
  const active = document.activeElement;
  if (!container.contains(active)) return null;
  return {
    item: active.closest('[data-key]')?.dataset.key ?? null,
    field: active.dataset.field ?? null,
  };
}

function restoreFocus(container, memo) {
  if (!memo) return;
  const item = memo.item
    ? [...container.querySelectorAll('[data-key]')].find((el) => el.dataset.key === memo.item)
    : container;
  const target = memo.field && item?.querySelector(`[data-field="${memo.field}"]`);
  (target ?? item)?.focus();
}

For a text input inside a re-rendered row, the memo must also carry the caret position and the selection direction, because refocusing an input places the caret at the end of its value. Reading selectionStart and selectionEnd before the render and writing them back after is the only way to keep a user’s cursor where they left it, and it must happen after the value has been restored to the new node.

If the item itself no longer exists after the render — it was filtered out, or deleted — the memo points at nothing and the fallback applies. Choosing that fallback is the same problem as the list case: the container, or the nearest surviving item.

Which elements deserve restoration, and which do not

Not every removal warrants a focus move. A notification that appears and disappears while the user types in a field should not move focus; doing so would interrupt input. The distinction is whether the removal destroyed the element the user was operating, or merely changed something else on the page.

Three cases where restoration is wrong:

Focus is inside a text field and a background update re-renders an unrelated region. Moving focus to that region steals the caret. Restoration should be scoped to the subtree that was re-rendered, and skipped when the focused element is not inside it.

The removed element was itself transient and its removal was the user’s intent, as when a dismissible banner’s close button is pressed. Focus should go somewhere sensible — the element the banner displaced, or the page’s main region — but there is no “former position” to restore.

The user has already moved focus elsewhere by the time the asynchronous removal completes. Checking document.activeElement === document.body before restoring prevents a late callback from yanking focus away from wherever the user has since gone. This check is essential for any restoration driven by a network response, where the delay is unbounded.

Detecting removal without polling

Restoration is normally driven by the code that performs the removal, which is the reliable approach: the operation knows what it is about to do and can capture state first. Where the removal happens outside the component’s control — a third-party script, a framework’s reconciliation — a MutationObserver on the container can detect that the focused node was removed.

const observer = new MutationObserver(() => {
  if (document.activeElement !== document.body) return; // focus was handled elsewhere
  if (!container.isConnected) return;
  container.focus(); // container carries tabindex="-1"
});
observer.observe(container, { childList: true, subtree: true });

The observer callback runs as a microtask after the mutations, so document.activeElement has already been fixed up to the body by the time it runs. That makes the body check a reliable signal that a removal destroyed focus, and also the reason the observer cannot distinguish “focus was removed” from “focus was never inside”. Scoping the observer to the container and checking membership before the mutation, rather than after, is what distinguishes the cases; the sketch above trades that precision for brevity.

Observer-based restoration is a last resort. It fires for every child mutation in the subtree, and on a list that re-renders frequently it runs often enough to matter. The direct approach — capture, mutate, restore — is cheaper and has no ambiguity about intent.

Where this connects to the rest of the model

Restoration is the mirror image of the initial focus move. A dialog records its opener and returns to it, as described in Containing Focus Inside a Modal Surface; a route change records the scroll position and the element that was focused; a deletion records the neighbour. All three are the same discipline: capture the semantic location before the DOM changes, re-resolve it after, and verify that focus actually landed where intended, since focus() reports nothing when it fails.

The verification step is what separates a restoration that works from one that appears to. Reading back document.activeElement after the call, and falling back to the container when the read does not match, converts a silent failure into a defined outcome. The rules governing which elements can accept focus at all are covered in Which Elements Can Actually Take Focus, and a restoration target that violates them will be refused without complaint.

References

  1. HTML Living Standard — Focus — WHATWG
  2. HTML Living Standard — Focus fixup rule — WHATWG
  3. DOM Standard — MutationObserver — WHATWG
  4. WAI-ARIA Authoring Practices Guide — Managing focus — W3C WAI