RouterEdit this page.md

Scroll restoration

@lazarv/react-server includes a complete scroll restoration system that handles window scroll, nested scrollable containers, hash navigation, and async content — with zero-flash restoration on page reload, automatic prefers-reduced-motion support, and per-route customization hooks.

The fastest way to enable scroll restoration is through the config file:

react-server.config.mjs
export default { scrollRestoration: true, };

This injects an early-restore script into <head> (runs before React hydrates — no flash) and auto-renders the <ScrollRestoration> component with default settings.

You can also pass options:

react-server.config.mjs
export default { scrollRestoration: { behavior: "smooth", // "auto" | "smooth" | "instant" }, };

For more control, render the component yourself in a client component instead of using the config option:

"use client"; import { ScrollRestoration } from "@lazarv/react-server/navigation"; export default function App({ children }) { return ( <> <ScrollRestoration behavior="smooth" /> {children} </> ); }

Place <ScrollRestoration> once at the top level of your app. It handles:

Props:

PropTypeDefaultDescription
behavior"auto" | "smooth" | "instant""auto"Scroll behavior passed to window.scrollTo(). Automatically falls back to "auto" when the user has prefers-reduced-motion enabled

Scroll positions are saved to sessionStorage keyed by a unique scroll key per history entry. On each navigation:

  1. The current scroll position is saved (window + all registered containers)
  2. If navigating forward — scroll to top (or #hash target)
  3. If navigating back/forward — restore saved position from sessionStorage

On page refresh, an early script in <head> runs before React hydrates and synchronously restores the saved position — preventing the flash-of-wrong-position that plagues most SPA scroll restoration solutions.

For async content (Suspense boundaries, Activity transitions), the component retries scroll restoration using requestAnimationFrame polling until the page is tall enough to reach the target position, up to 500ms.

Storage management: When sessionStorage quota is exceeded, the oldest ~50% of scroll entries are automatically evicted.

Use useScrollContainer to register nested scrollable elements (sidebars, chat panels, data tables) for automatic save/restore alongside the window scroll:

"use client"; import { useRef } from "react"; import { useScrollContainer } from "@lazarv/react-server/navigation"; export function Sidebar() { const ref = useRef(null); useScrollContainer("sidebar", ref); return ( <nav ref={ref} style={{ overflow: "auto", height: "100vh" }}> {/* sidebar content */} </nav> ); }

The id must be unique and stable across navigations and page reloads. When the user navigates away and presses Back, both the window scroll and the sidebar scroll are restored to their saved positions.

If the container element isn't mounted yet when restoration occurs (e.g. inside a Suspense boundary), the target position is deferred and applied as soon as the container registers.

You can also use the imperative API for non-React contexts:

import { registerScrollContainer, unregisterScrollContainer, } from "@lazarv/react-server/navigation"; // Register registerScrollContainer("chat-messages", element); // Cleanup unregisterScrollContainer("chat-messages");

Use useScrollPosition to customize scroll behavior per route. The handler is called on every navigation and can override or suppress scrolling:

"use client"; import { useCallback } from "react"; import { useScrollPosition } from "@lazarv/react-server/navigation"; export default function ScrollConfig() { useScrollPosition( useCallback(({ to, from, savedPosition }) => { const toPath = to.split("?")[0]; const fromPath = from?.split("?")[0]; // Skip scrolling for modal routes if (toPath.startsWith("/modal")) return false; // Keep scroll position when switching dashboard tabs if ( toPath.startsWith("/dashboard/") && fromPath?.startsWith("/dashboard/") ) { return false; } // Scroll to a custom position if (toPath === "/gallery") return { x: 0, y: 200 }; // Use default behavior (restore on back/forward, top on forward nav) return undefined; }, []) ); return null; }

Handler parameters:

ParameterTypeDescription
tostringThe URL being navigated to (path + search, e.g. "/products?sort=price")
fromstring | nullThe URL being navigated from, or null on initial page load
savedPosition{ x: number, y: number } | nullSaved position from sessionStorage (on back/forward), or null (on forward nav)

Return values:

ReturnEffect
{ x, y }Scroll to the specified position
falseSkip scrolling entirely
undefined / nullFall back to default behavior

Only the most recently registered handler is active. The handler is automatically unregistered when the component unmounts.

When the URL contains a #hash, scroll restoration automatically scrolls to the target element using element.scrollIntoView(). It looks up the element by id first, then falls back to [name="..."]. Hash scrolling takes priority over all other scroll behavior — including useScrollPosition handlers.

The scroll restoration system respects the prefers-reduced-motion media query. When the user has requested reduced motion, "smooth" behavior is automatically downgraded to "auto" (instant scroll). This applies to both window scroll and container scroll restoration.

All scroll restoration exports are available from @lazarv/react-server/navigation.

ExportTypeDescription
ScrollRestorationComponentRenders nothing visible. Manages scroll save/restore lifecycle. Props: { behavior? }
useScrollPositionHookRegister a per-route scroll behavior handler. Accepts (params) => ScrollPosition | false | undefined
useScrollContainerHookRegister a scrollable element for save/restore. Accepts (id: string, ref: RefObject<HTMLElement>)
registerScrollContainerFunctionImperative container registration. Accepts (id: string, element: HTMLElement)
unregisterScrollContainerFunctionRemove a registered container. Accepts (id: string)

Config option:

react-server.config.mjs
export default { // Enable with defaults scrollRestoration: true, // Or configure behavior scrollRestoration: { behavior: "smooth", // "auto" | "smooth" | "instant" }, };