Scroll Restoration in Infinite Scroll Lists
After reading this you will be able to return a user to their exact place in an infinite list after a detail-page visit and a Back press, using item anchoring rather than a pixel offset that no longer means anything.
← Back to Scroll Restoration Strategies
Prerequisites
Core Concept
Ordinary scroll restoration stores a pixel offset and reapplies it. That works when the page is the same height on the way back as it was on the way out. In an infinite list it never is.
The user scrolls to item 340 across seven loaded pages, taps into a detail view, and presses Back. The list component remounts holding page one — forty items, a few thousand pixels tall. Restoring an offset of 18,400 px does nothing, because the document is not that tall yet. If the application then loads the remaining pages, the offset becomes valid several seconds later, by which time the user has already started scrolling and gets yanked somewhere unexpected.
The fix is to change what is stored. Rather than “how far down the page”, record which item was at the top of the viewport and how far into it the user had scrolled. An item id is stable regardless of how many items are currently rendered, so restoration becomes: load enough pages to contain that item, find it, and scroll it to the position it occupied.
That reframing brings a second requirement with it. The list must be able to resume from an arbitrary point rather than always from page one — either by restoring the cached pages from the session or by requesting a window of items around the anchor. A cursor-based API makes this straightforward; an offset-based one makes it possible but fragile if items have been inserted in the meantime.
Implementation
Take the browser’s automatic restoration out of the picture first, then capture an anchor before leaving.
// TypeScript 5.x — turn off automatic restoration and capture an item anchor
if ("scrollRestoration" in history) {
history.scrollRestoration = "manual";
}
interface ListAnchor {
itemId: string;
/** Distance from the viewport top to the item's top, in CSS pixels. */
offset: number;
/** How many pages had been loaded, so we can resume the same window. */
pagesLoaded: number;
}
export function captureAnchor(listEl: HTMLElement): ListAnchor | null {
const items = listEl.querySelectorAll<HTMLElement>("[data-item-id]");
for (const item of items) {
const rect = item.getBoundingClientRect();
// The first item whose bottom is still below the viewport top is the anchor.
if (rect.bottom > 0) {
return {
itemId: item.dataset.itemId!,
offset: Math.round(rect.top),
pagesLoaded: Number(listEl.dataset.pagesLoaded ?? "1"),
};
}
}
return null;
}
Write the anchor into the history entry as the user leaves, so it travels with that entry rather than living in a global:
// TypeScript 5.x — store the anchor on the entry the user is leaving
export function saveAnchorBeforeLeaving(listEl: HTMLElement): void {
const anchor = captureAnchor(listEl);
if (!anchor) return;
history.replaceState({ ...(history.state as object ?? {}), listAnchor: anchor }, "");
}
Restoration is the interesting half, because the anchor may not be in the DOM yet. Load first, then position, and give up gracefully if the item has been deleted:
// TypeScript 5.x — restore by loading up to the anchor, then scrolling to it
export async function restoreAnchor(
listEl: HTMLElement,
loadPage: (page: number) => Promise<void>,
): Promise<boolean> {
const anchor = (history.state as { listAnchor?: ListAnchor } | null)?.listAnchor;
if (!anchor) return false;
// 1. Restore the same window of pages the user had open.
for (let page = 1; page <= anchor.pagesLoaded; page++) {
if (listEl.querySelector(`[data-item-id="${CSS.escape(anchor.itemId)}"]`)) break;
await loadPage(page);
}
// 2. Locate the anchor. It may be gone — deleted, filtered out, or moved.
const target = listEl.querySelector<HTMLElement>(
`[data-item-id="${CSS.escape(anchor.itemId)}"]`,
);
if (!target) return false; // caller falls back to the top of the list
// 3. Position it exactly where it was, after layout has settled.
await new Promise((resolve) => requestAnimationFrame(resolve));
const delta = target.getBoundingClientRect().top - anchor.offset;
window.scrollBy({ top: delta, behavior: "instant" as ScrollBehavior });
return true;
}
Two refinements make the difference between “usually right” and “right”:
// TypeScript 5.x — hold the anchor steady while images and lazy content settle
export function keepAnchorStable(listEl: HTMLElement, itemId: string, offset: number): () => void {
const target = listEl.querySelector<HTMLElement>(`[data-item-id="${CSS.escape(itemId)}"]`);
if (!target) return () => {};
// Content loading ABOVE the anchor changes its position; correct for it.
const observer = new ResizeObserver(() => {
const drift = target.getBoundingClientRect().top - offset;
if (Math.abs(drift) > 1) window.scrollBy({ top: drift, behavior: "instant" as ScrollBehavior });
});
observer.observe(listEl);
// Stop correcting as soon as the user takes control.
const stop = () => { observer.disconnect(); window.removeEventListener("wheel", stop); };
window.addEventListener("wheel", stop, { once: true, passive: true });
window.addEventListener("touchstart", stop, { once: true, passive: true });
return stop;
}
The complementary CSS is worth knowing about: overflow-anchor: auto — the default — already keeps the browser from shifting content when things load above the viewport, and explicitly disabling it on a list container is a common self-inflicted cause of jumping.
Verification
// @playwright/test v1.44
import { test, expect } from "@playwright/test";
test("returning from a detail page restores the user's place in the list", async ({ page }) => {
await page.goto("/products");
// Scroll far enough to load several pages.
for (let i = 0; i < 6; i++) {
await page.mouse.wheel(0, 4000);
await page.waitForTimeout(200);
}
const anchorId = await page.evaluate(() => {
const items = [...document.querySelectorAll<HTMLElement>("[data-item-id]")];
return items.find((el) => el.getBoundingClientRect().bottom > 0)?.dataset.itemId;
});
await page.locator(`[data-item-id="${anchorId}"] a`).click();
await expect(page.locator("[data-route='product-detail']")).toBeVisible();
await page.goBack();
// The anchor item must be back at the top of the viewport, within a few pixels.
const top = await page.locator(`[data-item-id="${anchorId}"]`)
.evaluate((el) => el.getBoundingClientRect().top);
expect(Math.abs(top)).toBeLessThan(8);
// And it must not drift afterwards as images finish loading.
await page.waitForTimeout(1500);
const settled = await page.locator(`[data-item-id="${anchorId}"]`)
.evaluate((el) => el.getBoundingClientRect().top);
expect(Math.abs(settled - top)).toBeLessThan(8);
});
The manual check is the one users perform: scroll deep, open an item, press Back, and see whether you are where you were. Then do it again on a throttled connection, which is where offset-based restoration fails most visibly.
Gotchas
- Restoring before the anchor is in the DOM does nothing. The scroll is clamped to the current document height and the later correction reads as a jump.
- Images without dimensions shift everything below them. Setting
widthandheight(oraspect-ratio) on list images removes most of the drift theResizeObserveris compensating for. - Stop correcting the moment the user scrolls. A correction loop that outlives the user’s first wheel event feels like the page is fighting them.
- Virtualised lists need index anchoring, not element anchoring. The element does not exist off-screen, so store the item index and the scroll container’s offset within it.
- Anchors must be per-entry, not global. Two list routes open in the same session will otherwise overwrite each other’s positions.
- A deleted anchor item is normal, not exceptional. Fall back to the top of the list rather than to the nearest surviving item, which lands the user somewhere they never were.
FAQ
Why not just cache the whole rendered list? You can, and for short lists it is simpler. For an infinite list the cache is unbounded, and keeping thousands of DOM nodes alive across navigations costs more memory than the feature is worth.
Does scrollRestoration = "manual" break normal pages? It disables the browser’s automatic restore for the whole document, so once you set it you own restoration everywhere — including on ordinary routes. Set it deliberately, not as a local fix.
What if items are inserted at the top between visits? That is precisely the case anchoring handles: the anchor item moves down, and the restore scrolls to wherever it now is, rather than to a stale pixel offset.
Should the anchor go in the URL? No. It is per-entry, per-session state that nobody would want in a shared link — the archetypal use of the history state object.
How does this interact with focus restoration? Restore focus first with preventScroll, then apply the anchor. Doing it the other way round lets the focus call undo the scroll you just set.
Related
- Scroll Restoration Strategies — the general problem and the browser’s own restoration behaviour.
- Manual Scroll Restoration for SPAs — the offset-based technique this replaces for variable-height content.
- Serialising Complex State into History Entries — where the anchor is stored and how to keep it small.