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:
- 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.
- 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. - Focus-induced scrolling. Moving focus calls the equivalent of
scrollIntoViewif the element is off-screen, which is what crosses the sentinel in the first place. Each Tab press can trigger an observer.
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
// 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.
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 });
}
}
/* 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.
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.
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.
Verification Steps
- Navigate with Tab and Shift+Tab only through several loads and confirm
document.activeElementnever 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: autoon 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.
Related
- Announcing Infinite Scroll Loads with aria-live — telling users content arrived
- Bidirectional Infinite Scroll: Loading Upward — the scroll mechanics of prepending
- Recycling DOM Nodes in Observer-Driven Lists — recycling without losing focus
↑ Back to Accessible Observer-Driven Interfaces