Keep keyboard focus stable during observer-driven loads by never replacing the focused node, inserting new content only after it (or compensating with scroll anchoring when inserting above), and restoring focus explicitly if a re-render removes it.

Problem / Scenario Context

A messaging app loads older messages when the user scrolls to the top, using a sentinel observer at the head of the list. A keyboard user presses Shift+Tab to move upward through messages. Focusing the oldest visible message scrolls it into view, which crosses the sentinel, which prepends fifty older messages. Two things go wrong: the page jumps so the focused message is no longer where the user was looking, and on some renders the framework re-creates the list, focus falls back to <body>, and the next Tab press starts from the top of the document.

This is the focus side of the concerns described in Accessible Observer-Driven Interfaces, and it matters for sighted keyboard users and screen-magnifier users as much as for screen-reader users.

Mechanics Explanation

Focus is attached to a DOM node. Three kinds of change can disturb it:

  1. Removal. If the focused node is removed — even if an identical node is inserted in its place — focus moves to the document body. Frameworks that re-key or re-create list items on update cause this.
  2. Insertion above. Inserting content above the focused node pushes it down the page. Focus stays on the node, but the node may leave the viewport. Browsers' scroll anchoring (overflow-anchor: auto) compensates by adjusting the scroll position so a chosen anchor node stays put — but it picks its anchor from what is on screen, not from the focused element, and it is disabled in some layouts.
  3. Focus-induced scrolling. Moving focus calls the equivalent of scrollIntoView if the element is off-screen, which is what crosses the sentinel in the first place. Each Tab press can trigger an observer.

Prepending Content Above the Focused MessageA viewport containing a focused message. The sentinel at the top has just been crossed and fifty older messages are inserted above. Without compensation the focused message is pushed below the viewport. With scroll anchoring or manual compensation the scroll offset grows by the inserted height and the message stays in place.sentinel crossed — loading older messagesfocused message — must stay putsame message after an uncompensated prependSolid blue frame: viewport (root).The inserted height must be added to the scroll offset in the same frame, or the focused message jumps out of view.

Comparison Table: Insertion Scenarios

Change Focus preserved? Visual position preserved? Fix
Append after focused item yes yes none needed
Prepend above, scroll anchoring active yes usually verify the anchor choice
Prepend above, anchoring disabled yes no measure and compensate scrollTop
Re-render replaces focused node no — goes to body no stable keys, or restore focus
Virtual list recycles focused row no no pin the focused row while it has focus

Minimal Reproducible Example

TypeScript
// Re-rendering the whole list on each load: focus is lost.
async function loadOlder(): Promise<void> {
  const older = await fetchOlder();
  messages = [...older, ...messages];
  list.innerHTML = messages.map(renderMessage).join('');   // every node replaced
}

new IntersectionObserver(([e]) => { if (e.isIntersecting) loadOlder(); })
  .observe(document.querySelector('#top-sentinel')!);

Focus a message, press Shift+Tab until the sentinel crosses, and check document.activeElement afterwards: it is <body>.

Production-Safe Solution

Insert, do not replace; compensate for inserted height explicitly; and restore focus defensively.

TypeScript
interface PrependOptions {
  scroller: HTMLElement;          // the scroll container
  list: HTMLElement;
  render: (item: Message) => HTMLElement;
}

interface Message { id: string; text: string }

export async function prependPreservingFocus(
  { scroller, list, render }: PrependOptions,
  older: Message[],
): Promise<void> {
  const focusedId = (document.activeElement as HTMLElement | null)?.closest<HTMLElement>('[data-id]')?.dataset.id;
  const beforeHeight = scroller.scrollHeight;
  const beforeTop = scroller.scrollTop;

  // 1. Insert new nodes; existing nodes (including the focused one) are untouched.
  const frag = document.createDocumentFragment();
  for (const m of older) frag.append(render(m));
  list.prepend(frag);

  // 2. Compensate in the same task, before the browser paints.
  scroller.scrollTop = beforeTop + (scroller.scrollHeight - beforeHeight);

  // 3. Defensive restore: if something re-rendered and dropped focus, put it back.
  if (focusedId && !list.contains(document.activeElement)) {
    list.querySelector<HTMLElement>(`[data-id="${CSS.escape(focusedId)}"]`)
      ?.focus({ preventScroll: true });
  }
}
CSS
/* Stop native anchoring from also adjusting, since we compensate manually. */
.message-scroller { overflow-anchor: none; }

Reading scrollHeight after the prepend forces a layout, but it is one layout in response to a user-driven load, and doing it before paint is exactly what prevents the visible jump. preventScroll: true on the restore matters: without it, focusing scrolls the element into view, which can cross the sentinel again and start another load.

In frameworks, the equivalent of step 1 is using stable keys — the message ID, never the array index — so the renderer moves and inserts nodes rather than recreating them.

Prepend Without Losing Focus or PlaceFour steps. Record the focused item's identifier and the scroll metrics. Prepend a fragment of new nodes without touching existing ones. Add the growth in scroll height to the scroll position before paint. If focus fell out of the list, restore it to the recorded item without scrolling.1RecordFocused item's data-id, scrollHeight and scrollTop.2Insert, don't replacePrepend a fragment; existing nodes keep their identity.3CompensatescrollTop += new scrollHeight − old scrollHeight, before paint.4Restore if lostfocus({ preventScroll: true }) on the recorded item.

Stopping Focus From Triggering Loads

The other half of the problem is that keyboard navigation itself crosses sentinels. Two refinements reduce surprise loads for keyboard users without taking the feature away:

Pause the sentinel while focus moves quickly. Track focusin events inside the list; if the user is tabbing (several focus changes within a second), defer sentinel-triggered loads until focus settles for a moment. A scroll-triggered load for a mouse user is unaffected.

TypeScript
let lastFocusMove = 0;
let sentinelVisible = false;
list.addEventListener('focusin', () => { lastFocusMove = performance.now(); });

const io = new IntersectionObserver(([e]) => {
  sentinelVisible = e.isIntersecting;
  if (sentinelVisible) scheduleLoad();
});

function scheduleLoad(): void {
  const sinceFocus = performance.now() - lastFocusMove;
  if (sinceFocus < 600) {
    // Keyboard user is still moving: check again once focus has settled.
    setTimeout(scheduleLoad, 600 - sinceFocus);
    return;
  }
  if (sentinelVisible) maybeLoad();   // only if the sentinel is still in view
}

declare function maybeLoad(): void;

Offer an explicit control. A visible "Load older messages" button at the top of the list is the keyboard equivalent of the sentinel and is announced as an actionable element. When it is used, moving focus to the newest of the loaded items is appropriate — the user asked for them.

Edge Cases

Images inside prepended content. If the older messages contain images without reserved dimensions, their height changes after your compensation runs, and the focused message drifts down as each image loads. Reserve space with width and height attributes, or observe the list with a ResizeObserver and re-apply the compensation while the user has not scrolled.

Framework batching. React and Vue may commit the prepend in a later microtask than your fetch resolution. Read the "before" metrics right before the commit — in React, in useLayoutEffect or getSnapshotBeforeUpdate; in Vue, in an onBeforeUpdate hook — and compensate in the matching post-commit hook, which still runs before paint.

Focus inside the item. If focus is on a button inside a message (a reaction button, a link), look up the closest item for the identifier but restore focus to the equivalent control, not the item container, or the user loses their exact position.

Screen readers in browse mode. A screen reader's virtual cursor is not DOM focus. It survives insertion elsewhere in the tree, but some readers re-read or jump when a large subtree is replaced. Inserting rather than replacing is what keeps the virtual cursor stable too.

Where Framework Hooks Fit the CompensationFour boxes in order. A snapshot hook reads scroll height and the focused item before the framework commits the new items. The framework commits the prepend. A layout hook compensates the scroll position and restores focus if needed. The browser then paints with the message still in place.Snapshot hookread scrollHeight,focused idCommitframework prependsnodesLayout hookcompensate, restorefocusPaintmessage has not moved

Verification Steps

  • Navigate with Tab and Shift+Tab only through several loads and confirm document.activeElement never becomes <body>.
  • Watch the focused item's position while loading older content above it; it should not move on screen.
  • Use a screen magnifier (macOS Zoom or Windows Magnifier) and confirm the magnified region does not jump.
  • Check with a screen reader that after a load the virtual cursor is still on the same message.
  • Test with scroll anchoring on and off to make sure the manual compensation and native anchoring are not both applied.

Common Mistakes to Avoid

  • Keying list items by index. Prepending shifts every index, so every node is re-created and focus is lost.
  • Leaving overflow-anchor: auto on while compensating manually. The two adjustments add up and overshoot.
  • Restoring focus without preventScroll. It can re-trigger the sentinel and loop.
  • Compensating in requestAnimationFrame. The browser paints the jumped frame first; compensate synchronously after the insertion.

FAQ

Does native scroll anchoring solve prepending on its own?

Often, for document-level scrolling. It chooses an anchor among visible elements and keeps it in place when content above changes. It can pick the wrong anchor, is suppressed when your code changes scroll position in the same frame, and behaves inconsistently in some nested scrollers — so chat-style lists usually compensate manually.

Why does focus go to the body when a node is removed?

Focus belongs to a specific node. When that node leaves the document, the browser has no successor to choose, so the active element reverts to the body. Nothing moves focus to the replacement automatically.

What about virtualised lists that recycle rows?

Keep the focused row rendered even when it is outside the window, for as long as it has focus. Most virtual list libraries support a pinned or sticky index for exactly this reason.

Is it ever right to move focus automatically after a load?

Yes, when the user explicitly requested the content — pressing a Load more button. Then moving focus to the first new item takes them where they asked to go.


↑ Back to Accessible Observer-Driven Interfaces