Restoring Focus and Scroll on Browser Back
Permalink to "Restoring Focus and Scroll on Browser Back"Returning to a list from a detail view is one of the most common journeys in a data application, and in a single-page application almost nothing about it is restored automatically. The browser may put the scroll offset back; the framework rebuilds the DOM; and focus — the thing keyboard and screen reader users navigate by — is gone. This page covers what to store in history state, when it is safe to apply it, and the fallback for a row that no longer exists.
It extends focus management in single-page apps, and it uses the same resolve-then-move discipline as restoring focus after closing complex modals.
Spec reference
Permalink to "Spec reference"SC 2.4.3 Focus Order (Level A) requires focusable components to receive focus in an order that preserves meaning and operability. A back navigation that leaves focus on document.body while the visual scroll position is restored fails that plainly: the visual state says “you are back where you were” and the focus state says “you are at the top of a new document”.
SC 3.2.3 Consistent Navigation (Level AA) is the second consideration. If returning to a list sometimes restores the position and sometimes does not — depending on whether data was cached — the navigation is not consistent.
The mechanism is the History API. history.replaceState attaches a state object to the current entry without creating a new one, which is exactly right for recording where the user was as they leave. history.scrollRestoration can be set to manual when the application restores scroll itself, which is usually necessary when data loads asynchronously and the browser’s automatic restore fires before the content exists.
When to use — and when not to
Permalink to "When to use — and when not to"Apply this to every list-to-detail-and-back journey: a table row opening a record, a search result opening a document, a dashboard tile opening a report.
Do not apply it to a forward navigation. Arriving at a list for the first time should put focus at the top of the view, as described in the parent guide — restoring a stale position would be actively confusing.
Do not restore focus into content that has changed meaningfully. If the underlying data was mutated by the detail view — a record deleted, a status changed — restore focus near the old position and announce what changed, rather than silently landing the user somewhere that no longer matches their memory.
Annotated code example
Permalink to "Annotated code example"// On leaving the list: record where the user was, without adding a history entry
function rememberPosition() {
const focused = document.activeElement?.closest('[data-row-id]');
history.replaceState({
...history.state,
listState: {
filters: currentFilters(),
page: currentPage(),
scrollY: window.scrollY,
focusRowId: focused?.dataset.rowId ?? null, // an ID, never an index
},
}, '');
}
// On returning: restore data first, focus last
window.addEventListener('popstate', async (e) => {
const s = e.state?.listState;
if (!s) return;
history.scrollRestoration = 'manual'; // we restore, once the rows exist
applyFilters(s.filters);
await renderPage(s.page); // the rows must exist before we focus
window.scrollTo({ top: s.scrollY, behavior: 'instant' });
const row = s.focusRowId && document.querySelector(
`[data-row-id="${CSS.escape(s.focusRowId)}"]`);
if (row) {
row.setAttribute('tabindex', '-1');
row.focus({ preventScroll: true }); // scroll was already restored
announce(`Returned to page ${s.page}. Focus on ${labelOf(row)}.`);
} else {
// SC 2.4.3: never leave focus on document.body
const fallback = nearestSurvivingRow(s.focusRowId) || resultsContainer;
fallback.setAttribute('tabindex', '-1');
fallback.focus({ preventScroll: true });
announce('That row is no longer available. Focus moved to the nearest row.');
}
});
preventScroll: true on the focus call matters here. Without it the browser scrolls the focused element into view, undoing the scroll restoration that just ran and leaving the list in a position the user did not choose.
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Situation | Expected result | Failure indicator |
|---|---|---|
| Back from a detail view | Focus lands on the row that was opened | The reader starts from the page heading |
| Back after the row was deleted | Focus lands nearby; the change is announced | Silence, and focus on the body |
| Back before data has loaded | Focus waits for the rows | Focus moves to a container that is still empty |
| Forward again | Focus follows the same rules | Position is restored only in one direction |
Integration context
Permalink to "Integration context"History state and filter state overlap. If filters are already encoded in the URL — which is the recommendation in accessible filtering and faceted search — then the state object only needs the scroll offset and the focus key, and the rest is reconstructed from the URL. That is the more robust arrangement, because it also survives a refresh and a shared link.
Where the list is virtualized, restoring focus may require scrolling the window to the remembered row before the element exists. Restore the data offset first, let the window render, then query for the row.
Gotchas
Permalink to "Gotchas"Focusing before the rows render. The query returns null and the fallback fires every time. Await the render.
Storing a DOM index. Data changes between visits; index 12 is a different row.
Double scroll. Browser automatic restoration plus your own produces a visible jump. Set scrollRestoration = 'manual' when you take over.
Announcing on every history event. popstate also fires for in-page anchors in some browsers. Guard on the presence of your own state object.
FAQ
Permalink to "FAQ"Does the browser restore focus on back navigation?
Not in a single-page application. Browsers restore scroll position for classic navigations and, with scrollRestoration set to auto, often for history navigations too — but focus is never restored, because the framework re-creates the DOM and the element that had focus no longer exists. Restoring it is application work, and it needs a stable key stored in history state.
What should be stored in history state?
Enough to reconstruct the view and the user position: the filter set, the page or scroll offset, and the id of the element that had focus. Store ids, never DOM indexes — the data may have changed between leaving and returning, and an index will point at a different row.
Should a restored position be announced?
Yes, once, if the context differs from a fresh load. “Returned to page 3 of 12, 587 rows, focus on Nordics AB” tells a returning user where they are. Without it, a screen reader user hears the page from the top and has no way to know their previous position was restored at all.
Related
Permalink to "Related"- Focus management in single-page apps — the parent guide and the forward-navigation rules
- Managing route change focus in Angular and Vue — the framework hooks that fire at the right moment
- Restoring focus after closing complex modals — the same resolve-then-move pattern