Navigation

Scroll Restoration

Remembers where every scroll container was when a history entry was left and puts it back on Back — including the inner panes the browser never restores.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/* -------------------------------------------------------------------------- */
/* history-entry bookkeeping — one shared coordinator for every instance        */
/* -------------------------------------------------------------------------- */

/**
 * Key under which every history entry carries its own id, inside `history.state`.
 *
 * **Why the id lives in `history.state` and not in the URL**: two visits to
 * `/inbox` are two *different* entries with the same URL. Keying saved positions

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/scroll-restoration.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "Scroll Restoration" component. No
dependencies beyond React, Tailwind and a cn() class-merge helper.

Contract
- Export forwardRef<HTMLDivElement, ScrollRestorationProps> extending
  React.HTMLAttributes<HTMLDivElement>, so consumers can pass ref, data-*,
  onScroll, tabIndex and aria-* straight through to the container.
- Props: name?: string (default "main") identifying this container within one
  history entry; target?: "self" | "window" (default "self"); strategy?:
  "content-height" | "immediate" (default "content-height"); timeout?: number
  ms, clamped to 0-30000 (default 3000); resetOnNewEntry?: boolean (default
  true); onRestore?: (detail: ScrollRestoreDetail) => void.
- ScrollRestoreDetail = { name, savedTop, appliedTop, outcome, elapsed } with
  outcome: "restored" | "clamped" | "timeout" | "canceled".

Behavior
- Identity: every history entry carries its own random id inside
  history.state, written with replaceState that SPREADS the existing state
  (routers keep their own bookkeeping there — Next's App Router reloads the
  page when a popped entry loses its marker). Positions are keyed by that id,
  never by pathname: two visits to /inbox are two entries with one URL, and
  keying by URL restores the first visit's position into the second.
- Storage: an in-memory Map of entry id -> { container name -> { top, height } },
  flushed to sessionStorage on pagehide and visibilitychange:hidden and
  reloaded on install, so a reload of the same entry still restores. Cap the
  map (e.g. 40 entries, evicting the oldest) and wrap every storage call in
  try/catch — private mode must degrade to "nothing to restore", not throw.
- Navigation detection: pushState and replaceState fire no event, so wrap both
  once, module-level, refcounted by mounted instances. A "push" always mints a
  FRESH entry id (a router that copies the old state would otherwise let two
  entries share one id) and tells every container it is somewhere new. A
  "replace" is the same entry: keep the position, just stamp the id back onto
  the router's new state object. "pop" comes from a popstate listener and reads
  the id already on that entry.
- Ordering: freeze every mounted container's position BEFORE the entry id
  changes. The wrappers run synchronously inside pushState, i.e. before the
  router re-renders, so the outgoing screen is still on-screen and measurable.
- Restore vs top: a container restores when the entry has a saved position,
  and scrolls to 0 when it does not — that is the difference between "came
  back" and "went somewhere new", and getting it wrong is how visitors land
  halfway down a page they have never seen. resetOnNewEntry=false opts a
  persistent shell element (a nav sidebar) out of the reset.
- Restore timing, the hard part: on mount and on every pop, run a rAF loop.
  Each frame compute maxTop = scrollHeight - clientHeight, apply
  min(savedTop, maxTop) with scrollTo({ behavior: "instant" }), and finish
  "restored" once the saved position is actually reachable. Applying the best
  effort every frame (rather than waiting for the exact value) means the
  container follows the content down as it loads. Give up when
  performance.now() - start exceeds timeout, reporting "clamped" if the
  content height has been still for ~250ms (the page really is shorter now)
  or "timeout" if it was still growing. strategy="immediate" does one frame
  and reports "clamped" when it cannot reach the target.
- Never fight the visitor: while a restore is in flight, listen for wheel,
  touchstart, pointerdown and keydown on the scroll target and finish
  "canceled" on the first one.
- Turn the browser's own restoration off (history.scrollRestoration =
  "manual") on the first mount and put the previous value back on the last
  unmount — two writers on one scrollTop is a race. Unwrap pushState /
  replaceState only if the global is still your function, so a wrapper
  installed after yours is not dropped.
- Cleanup: cancel the rAF, remove the takeover listeners, unregister the
  container and save its final position in a LAYOUT-effect cleanup — a
  detached node reports scrollTop 0, so a passive cleanup saves zeros for
  every container that unmounts with its route.

Rendering & styling
- One div: cn("overflow-y-auto", className) for target="self", cn("contents",
  className) for target="window" (display:contents adds no box, so the
  window-scroll instance costs nothing in the layout). data-scroll-restoration
  = name for debugging. Remaining props spread onto the root.
- Semantic tokens only; the component paints nothing of its own — height,
  borders and background come from the consumer's className.
- Motion: restores use behavior "instant", never "smooth" — "auto" would obey
  a CSS scroll-behavior: smooth up the tree and animate the jump, which both
  fights the next frame's measurement and hands reduced-motion visitors
  movement they opted out of. There is no decorative animation to suppress.
- Accessibility: the element is a plain scroll container. If its subtree has
  no focusable children, pass tabIndex={0} and an aria-label so keyboard and
  screen-reader users can scroll it — both flow through the spread props.

Customization levers
- Granularity: one instance per pane. name is the whole coordination story —
  "main", "sidebar", "inspector" restore independently within one entry.
- Page scroll: render one <ScrollRestoration target="window" /> if the
  document itself scrolls; with scrollRestoration set to manual the browser
  will not do it for you.
- Patience: timeout is how long you are willing to wait for a slow list. Lower
  it (300-600ms) for snappy cached routes, raise it for streamed content;
  strategy="immediate" opts out of waiting entirely.
- Persistence: resetOnNewEntry=false for anything that outlives the route.
- Observability: onRestore is where to log "how often do we actually make it
  back", or to fall back to your own anchor-based restore when the outcome is
  "timeout".
- Storage scope: swap sessionStorage for localStorage to survive a tab
  restart, or drop persistence entirely and keep the Map in memory.

Concepts

  • History-entry identity, not URL identity — each entry gets a random id inside history.state, and a push always mints a new one. Two visits to the same URL are two entries, so the first visit's position can never be restored into the second; a router that copies the previous entry's state cannot smuggle an id forward either.
  • Push versus poppushState fires no event, so it is wrapped. Without that distinction "came back" and "went somewhere new" look identical, and half of all navigations land the visitor mid-page on a screen they have never seen.
  • Snapshot before the id flips — the wrappers run synchronously inside pushState, before the router re-renders, so the outgoing screen is still mounted and measurable. Saving from an unmount effect instead records zeros, because a detached node reports scrollTop 0.
  • Restore as a loop, not a write — the saved position usually does not exist yet at mount: the list is still being fetched. Each frame applies the best position the current height allows and stops once the real one becomes reachable, with a timeout so a page that never comes back tall enough does not spin forever.
  • Three ways to not make itclamped (the content settled shorter, that position is gone), timeout (still growing when the budget expired), canceled (the visitor scrolled and took over). They are reported rather than hidden, so you can measure how often restoration actually works.
  • Manual scroll restoration — the browser's own restoration is switched off while any instance is mounted and switched back on the last unmount. It only ever knew about the document scroller anyway, and leaving it on means two writers racing over one scrollTop.

On This Page