npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

next-manual-scroll-restoration

v0.1.0

Published

Manual scroll restoration hook for Next.js App Router with customizable navigation policies

Downloads

188

Readme

next-manual-scroll-restoration

Manual scroll restoration for the Next.js App Router, with customizable navigation policies.

한국어 문서 (Korean)

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 onNavigate callback
  • Pluggable storage: sessionStorage by default, swappable via the PositionStorage interface, with built-in LRU eviction (maxEntries)
  • Imperative API: saveScrollPosition, getScrollPosition, clearScrollPosition, preserveNextNavigation
  • basePath support, restore on reload, forced save on app background / WebView teardown
  • debug option that logs every navigation decision and restore result

Installation

npm install next-manual-scroll-restoration

peerDependencies: 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 enabled are 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

  1. Patches history.pushState / replaceState to save the current scroll right before navigating, and records the navigation type (push/replace)
  2. Records the pop type and saves the current position on popstate
  3. When the route commits to React (useLayoutEffect), decides the scroll behavior from the recorded type plus the onNavigate policy
  4. If the document is not tall enough to restore, waits for height changes via ResizeObserver and gives up after the timeout
  5. Manages per-route-key save blocking so the router's own scrollTo(0) right after a navigation cannot overwrite stored values
  6. 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. Call preserveNextNavigation() 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