next-manual-scroll-restoration
v0.1.0
Published
Manual scroll restoration hook for Next.js App Router with customizable navigation policies
Downloads
188
Maintainers
Readme
next-manual-scroll-restoration
Manual scroll restoration for the Next.js App Router, with customizable navigation policies.
The App Router has no routeChangeStart-style events, which makes it hard to rely on the browser's native scroll restoration. This library patches the History API to save the scroll position right before every navigation, tracks whether each navigation is a push / replace / pop, and applies a per-type scroll policy.
Features
- push → top, back/forward → restore: sensible per-navigation-type defaults built in
- Async rendering support: restores even when API data or images arrive late, by watching document height with
ResizeObserver(3s timeout by default) - Customizable policy: override per-route behavior with the
onNavigatecallback - Pluggable storage: sessionStorage by default, swappable via the
PositionStorageinterface, with built-in LRU eviction (maxEntries) - Imperative API:
saveScrollPosition,getScrollPosition,clearScrollPosition,preserveNextNavigation - basePath support, restore on reload, forced save on app background / WebView teardown
debugoption that logs every navigation decision and restore result
Installation
npm install next-manual-scroll-restorationpeerDependencies: next >= 14, react >= 18
Basic usage
Call the hook exactly once, in a client component at the app root. It uses useSearchParams internally, so it must live under a Suspense boundary.
'use client';
import { useManualScrollRestoration } from 'next-manual-scroll-restoration';
export function ScrollRestoration() {
useManualScrollRestoration();
return null;
}// app/layout.tsx
import { Suspense } from 'react';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<Suspense>
<ScrollRestoration />
</Suspense>
{children}
</body>
</html>
);
}Options
useManualScrollRestoration({
enabled: true,
storageKeyPrefix: 'scroll-restoration',
basePath: '', // must match basePath in next.config
maxEntries: 50, // LRU cap for the default storage
restoreTimeoutMs: 3000, // give up restoring after this long
saveThrottleMs: 100, // throttle for scroll-event saves
storage: undefined, // custom PositionStorage implementation
onNavigate: undefined, // custom policy callback
resolvePosition: undefined, // custom scroll position reader
debug: false, // log decisions and restore results
});All options except
enabledare read once, on the first mount.
Custom policies with onNavigate
Receives a NavigationContext and returns 'restore' | 'top' | 'preserve' | 'none'. Returning undefined (or nothing) falls back to the default policy, so you only need to define the exceptions.
useManualScrollRestoration({
onNavigate: (ctx) => {
// keep the scroll when pushing into the search page
if (ctx.type === 'push' && ctx.toKey.startsWith('/search')) return 'preserve';
// everything else: default policy
},
});The default policy (defaultScrollPolicy, exported):
| Situation | Behavior |
|---|---|
| Initial entry (including reload) | restore if a position is stored |
| Same pathname, only the query changed (tabs/filters) | preserve |
| push / replace to a different pathname | top |
| pop (back/forward) | restore if a position is stored |
Imperative API
import {
saveScrollPosition, // force-save the current position now
getScrollPosition, // read the stored position for a route key
clearScrollPosition, // drop the stored position (e.g. after a list reset)
preserveNextNavigation, // keep the scroll for the next navigation only
} from 'next-manual-scroll-restoration';preserveNextNavigation is the escape hatch for <Link scroll={false}> and router.push(..., { scroll: false }), which this library cannot detect on its own:
<Link
href="/list?sort=recent"
scroll={false}
onClick={() => preserveNextNavigation()}
>
Sort by recent
</Link>clearScrollPosition is useful when a stored position became meaningless, e.g. after resetting a list with new filters:
clearScrollPosition(); // active route
clearScrollPosition('/list'); // specific route key (pathname + search, without basePath)resolvePosition for scroll locks
If your app locks scrolling with body { position: fixed; top: -N px } while a modal is open, override how the current position is read:
useManualScrollRestoration({
resolvePosition: () =>
document.body.style.position === 'fixed'
? { x: window.scrollX, y: Math.abs(parseInt(document.body.style.top || '0', 10)) }
: { x: window.scrollX, y: window.scrollY },
});Custom storage
import type { PositionStorage } from 'next-manual-scroll-restoration';
const memoryStorage = (): PositionStorage => {
const map = new Map();
return {
get: (key) => map.get(key) ?? null,
set: (key, position) => map.set(key, position),
remove: (key) => map.delete(key), // required for clearScrollPosition
};
};
useManualScrollRestoration({ storage: memoryStorage() });How it works
- Patches
history.pushState/replaceStateto save the current scroll right before navigating, and records the navigation type (push/replace) - Records the pop type and saves the current position on
popstate - When the route commits to React (
useLayoutEffect), decides the scroll behavior from the recorded type plus theonNavigatepolicy - If the document is not tall enough to restore, waits for height changes via
ResizeObserverand gives up after the timeout - Manages per-route-key save blocking so the router's own
scrollTo(0)right after a navigation cannot overwrite stored values - Force-saves on
pagehide/beforeunload/visibilitychange(reload, app background, WebView teardown)
Limitations
- App Router only. Does not work with the Pages Router.
- The hook must be mounted once per app. Duplicate mounts are ignored with a dev-mode warning.
<Link scroll={false}>/router.push(..., { scroll: false })cannot be detected. CallpreserveNextNavigation()before those navigations instead.- Hash (
#anchor) changes are not part of the route key. - The scroll container is the window (document). Inner scrollable elements are not supported.
License
MIT
