core accessibility principles for modern frameworks

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 Order
  • 2.4.7 Focus Visible
  • 3.2.3 Consistent Navigation
  • 4.1.3 Status Messages
What the browser restores, and what a router must restore itself Two columns compare a full page load with a client-side route change after pressing Back. On a full load the browser restores the scroll position, restores focus to the previously focused element where it can, and announces the new page title. After a client-side route change the router restores nothing: scroll starts at the top, focus falls to the document body, and no announcement is made unless the application makes one. Back on a full page load Scroll position restored from the browser's history entry Focus returned where possible bfcache keeps the live document Page title announced a real navigation occurred Nothing to implement Back on a client-side route Scroll jumps to the top the DOM was replaced Focus falls to <body> the focused node no longer exists Nothing is announced no navigation from the AT's view All three are yours to restore The user's expectation comes from the left column; a router that ignores it breaks a control everyone relies on.

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.state is 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.scrollRestoration at 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 — a data-focus-key, an id, or an index within a known list.
  • Store it in history.state for 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.

Ordering the restoration steps after a Back navigation A five-step sequence. The popstate event fires and the router begins rendering the previous view. The application waits for the restored content to have a measurable height, using a layout effect rather than a timeout. Focus is then set with preventScroll true so it does not move the viewport. The saved scroll position is applied. Finally a polite status message names the restored view. A warning notes that focusing before setting scroll, without preventScroll, undoes the restoration. 1 · popstate router renders 2 · layout ready height measurable 3 · focus preventScroll: true 4 · scrollTo saved offset 5 · announce polite, once The ordering bug Calling focus() without preventScroll scrolls the target into view, so step 4 appears to work and then the page jumps — the classic "it restores, then it doesn't" report.

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.
Fallback chain when the saved focus target has disappeared A four-step fallback. First try the element matching the saved focus key. If it is gone, try its nearest surviving ancestor, such as the list row. If that is gone too, focus the list container itself. As a last resort focus the main region, which carries tabindex minus one. A final box marked never shows focus falling to the document body, which loses the user's place with no error reported. Saved focus key the exact control Nearest ancestor the surviving row List container tabindex="-1" main region last resort Focus on <body> never — the failure nothing reports Each step degrades the experience a little; the last box destroys it, so the chain must never reach past main.

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.