Restoring Scroll & Focus on Browser Back Navigation
The Back button is the most-used control in any browser, and single-page apps routinely break it. A multi-page site restores scroll position and focus for free; a client-side router replaces the DOM and leaves the user at the top of a rebuilt page with focus on <body>. For a sighted mouse user that is an annoyance. For someone who tabbed twelve times to reach a link, followed it, and pressed Back, it means doing all twelve presses again.
This guide covers restoring both — scroll and focus — without fighting the browser's own restoration. It deepens Focus Management Strategies for SPAs.
WCAG Success Criteria Addressed:
2.4.3 Focus Order2.4.7 Focus Visible3.2.3 Consistent Navigation4.1.3 Status Messages
Prerequisites
- A client-side router with a navigation lifecycle you can hook into (Next.js App Router, React Router, Nuxt, or similar).
- Somewhere to persist a small amount of state per history entry —
history.stateis ideal because the browser scopes and discards it for you. - A polite live region for announcements; see announcing client-side route changes in React.
Let the Browser Do What It Can
Before adding restoration logic, check what the browser already does. Modern engines restore scroll for history navigations automatically unless the app has disabled it — and many SPAs disable it, then reimplement it badly.
Implementation Guidelines:
- Leave
history.scrollRestorationat its default'auto'unless you have a concrete reason to change it. - If you set it to
'manual'to control forward navigations, restore it for back/forward entries yourself; do not leave the browser disabled and unassisted. - Check whether the framework router already opts in — several set
'manual'on your behalf, which is the usual reason "the browser used to handle this" stops being true. - Test with the back-forward cache: a page restored from bfcache keeps its DOM and its focus, so verify your restoration logic does not run twice and fight it.
// Only take over when you are prepared to do the whole job.
if ('scrollRestoration' in history) {
history.scrollRestoration = 'auto';
}
Store the Focus Target With the History Entry
Scroll is a number; focus is an element, and elements do not survive a DOM replacement. What survives is a selector, and the reliable way to keep one is to save it against the history entry the user is leaving.
Implementation Guidelines:
- On navigation away, record a stable identifier for
document.activeElement— adata-focus-key, an id, or an index within a known list. - Store it in
history.statefor the outgoing entry, so it comes back with the entry rather than leaking across sessions. - Prefer identifiers that survive re-rendering: a database id beats a DOM index, which changes when the list reorders.
- Restore after the view has rendered, and verify the element exists before focusing it — data may have changed while the user was away.
'use client';
// Save the focus key against the entry we are leaving.
function rememberFocus() {
const active = document.activeElement as HTMLElement | null;
const key = active?.closest<HTMLElement>('[data-focus-key]')?.dataset.focusKey;
if (!key) return;
history.replaceState({ ...history.state, focusKey: key }, '');
}
// Restore it after the restored view has rendered.
function restoreFocus() {
const key = history.state?.focusKey;
if (!key) return;
const target = document.querySelector<HTMLElement>(`[data-focus-key="${key}"]`);
// A11y rationale: 2.4.3 Focus Order — returning the user to the control they
// left preserves their place instead of restarting at the top of the page.
target?.focus({ preventScroll: true });
}
preventScroll: true matters here: focusing an element scrolls it into view by default, which would override the scroll position you are about to restore. Focus first with scrolling suppressed, then set the scroll position.
Restore Scroll Without a Flash
Restoring scroll after React has painted produces a visible jump. Restoring it before the content exists produces no effect at all. The window between the two is the whole problem.
Implementation Guidelines:
- Save
window.scrollY(and any scrollable container offsets) on navigation away, alongside the focus key. - Restore in a layout effect after the restored view renders, so the DOM has height and the browser can actually scroll there.
- If content loads asynchronously, restore once when the measured height is at least the saved offset — a fixed timeout is a guess that breaks on slow connections.
- Skip restoration entirely for forward navigations to new routes; those should start at the top.
useLayoutEffect(() => {
if (navigationType !== 'POP') return;
const saved = history.state?.scrollY as number | undefined;
if (typeof saved !== 'number') return;
restoreFocus(); // preventScroll keeps the viewport still
window.scrollTo(0, saved); // then put the viewport back
}, [pathname, navigationType]);
Announce the Restored View Once
Restoring position silently leaves screen reader users with no signal that anything happened. One polite message closes the gap.
Implementation Guidelines:
- Announce the restored page's name — the same message a forward navigation would produce.
- Say it once, politely, and do not repeat it if the user presses Back several times quickly.
- Do not describe the restoration itself ("Scroll position restored"); it is implementation detail nobody asked about.
- Keep the announcement independent of the focus move: if focus lands on a named control, the screen reader will read that too, and the two should not repeat each other.
How to Verify
- Twelve-tab test. Tab deep into a list, follow a link, press Back. Focus should return to the link you followed, with a visible ring, and one further Tab should continue from there.
- Scroll test. Scroll halfway down a long list, navigate, press Back. The viewport should land where you left it, with no visible jump afterwards.
- Slow-connection test. Throttle the network to 3G and repeat. Restoration should still land correctly once content arrives, not scroll to a stale offset first.
- Screen reader pass. Confirm exactly one announcement per Back navigation, naming the restored view.
- Data-change test. Delete the item that had focus, then press Back. The restore must fail gracefully — focus a stable container rather than throwing or landing on
<body>.
Common Accessibility Mistakes
- Setting
scrollRestoration = 'manual'and forgetting to implement it. The browser stops helping and nothing replaces it. - Focusing without
preventScroll. The focus call scrolls the target into view and undoes the scroll restoration a line later. - Restoring on a
setTimeout. It works on a fast machine and fails on a slow one; measure the layout instead. - Storing DOM indices as focus keys. A reordered or filtered list sends focus to the wrong item, which is worse than sending it nowhere.
- Restoring focus on forward navigations. New routes should start at the top with focus on the main region — restoration is for history entries.
- Announcing every restoration step. One message about the view; nothing about scroll offsets.
Conclusion
Back-button restoration is a small amount of code in a specific order: save a focus key and a scroll offset against the outgoing history entry, wait for the restored view to have real layout, focus with preventScroll, set the scroll position, then announce the view once. The ordering is where most implementations fail, and the symptom — restores correctly and then jumps — is distinctive enough to diagnose in seconds once you know it. Get it right and the router stops undoing work the browser used to do for nothing.
Frequently Asked Questions
Should I restore focus or scroll first?
Focus first, with preventScroll: true, then scroll. Focusing without that option scrolls the element into view, which overrides whatever you set afterwards. Doing it in this order means the two operations do not fight.
What if the previously focused element no longer exists?
Fall back progressively: the nearest surviving ancestor, then the list container, then the page's <main> with tabindex="-1". Never leave focus on <body> — the user loses every tab stop they had made, and nothing reports the failure.
Does this apply to modals and drawers too? The same principle applies but the mechanism differs. Overlays should restore focus to their trigger when they close, which is a local concern rather than a history one — covered in Keyboard Navigation Patterns for Modals. If an overlay pushes a history entry, treat closing it as a Back navigation and restore accordingly.
How does this interact with infinite scroll? Badly, which is one more argument against infinite scroll. Restoring an offset into a list that has not been re-fetched puts the user in empty space. If you must support it, persist the number of loaded pages with the history entry and re-fetch before restoring — or switch to a "Load more" button with real pagination.
Is history.state big enough for this?
Comfortably. It is limited to a few hundred kilobytes in practice, and a focus key plus a scroll offset is a few dozen bytes. Keep large payloads out of it — it is serialised on every navigation, and bloating it slows down the very interaction you are trying to improve.
Related guides
- Focus Management Strategies for SPAs — the parent guide and the wider focus model.
- Handling Focus Restoration After Dynamic Route Changes — forward navigations and where focus should land.
- Focus Management in Multi-Step Wizards — moving between steps without losing the user.
- Announcing Client-Side Route Changes in React — the polite announcement this restoration needs.