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.

Restoring scroll and restoring focus Comparison of scroll restoration and focus restoration showing that each serves a different group and neither substitutes for the other. Restoring scroll and restoring focus✓ Scroll restorationHandled by the browser in many casesServes sighted users returning to a listSays nothing to a screen readerCan be wrong when data loads after the restore✗ Focus restorationNever automatic in a single-page applicationServes keyboard and screen reader usersRequires a stable key stored in history stateMust be announced when the context has changed
The browser restores scroll; nothing restores focus but you.

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.

What a back navigation has to restore Timeline of a back navigation showing history state read, data restored, scroll position applied and finally focus moved and announced. What a back navigation has to restoreBack pressedpopstate firesState readfilters, page, scroll,focus keyView restoredrows renderedFocus + announcethe remembered row, thenthe summarytime across one back navigation →
Scroll without focus leaves screen reader users at the top of a page that looks scrolled.

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
Storing enough state to return to Three-step approach: write a focus key into history state on navigation away, restore it on popstate, and fall back gracefully when the row no longer exists. Storing enough state to return toOn leavinghistory.replaceState with the focused row id and scroll offsetReplace, not push — this is not a new entryOn returningrestore filters and page, then wait for the rowsFocus cannot move before the row existsIf the row is gonefocus the nearest surviving row, then announce"Row no longer available. Focus moved to thenext row."
A row id survives a re-fetch; a DOM index does not.

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.

Permalink to "Related"

Back to Focus Management in Single-Page Apps