Skip to content

Instantly share code, notes, and snippets.

@claus
Created May 14, 2020 05:35
Show Gist options
  • Star 95 You must be signed in to star a gist
  • Fork 14 You must be signed in to fork a gist
  • Save claus/992a5596d6532ac91b24abe24e10ae81 to your computer and use it in GitHub Desktop.
Save claus/992a5596d6532ac91b24abe24e10ae81 to your computer and use it in GitHub Desktop.
Restore scroll position after navigating via browser back/forward buttons in Next.js
import useScrollRestoration from "utils/hooks/useScrollRestoration";
const App = ({ Component, pageProps, router }) => {
useScrollRestoration(router);
return <Component {...pageProps} />;
};
export default App;
import { useEffect } from 'react';
import Router from 'next/router';
function saveScrollPos(url) {
const scrollPos = { x: window.scrollX, y: window.scrollY };
sessionStorage.setItem(url, JSON.stringify(scrollPos));
}
function restoreScrollPos(url) {
const scrollPos = JSON.parse(sessionStorage.getItem(url));
if (scrollPos) {
window.scrollTo(scrollPos.x, scrollPos.y);
}
}
export default function useScrollRestoration(router) {
useEffect(() => {
if ('scrollRestoration' in window.history) {
let shouldScrollRestore = false;
window.history.scrollRestoration = 'manual';
restoreScrollPos(router.asPath);
const onBeforeUnload = event => {
saveScrollPos(router.asPath);
delete event['returnValue'];
};
const onRouteChangeStart = () => {
saveScrollPos(router.asPath);
};
const onRouteChangeComplete = url => {
if (shouldScrollRestore) {
shouldScrollRestore = false;
restoreScrollPos(url);
}
};
window.addEventListener('beforeunload', onBeforeUnload);
Router.events.on('routeChangeStart', onRouteChangeStart);
Router.events.on('routeChangeComplete', onRouteChangeComplete);
Router.beforePopState(() => {
shouldScrollRestore = true;
return true;
});
return () => {
window.removeEventListener('beforeunload', onBeforeUnload);
Router.events.off('routeChangeStart', onRouteChangeStart);
Router.events.off('routeChangeComplete', onRouteChangeComplete);
Router.beforePopState(() => true);
};
}
}, [router]);
}
@claus
Copy link
Author

claus commented Sep 14, 2022

  useEffect(
    () => {
      if (
        typeof window === undefined ||
        !('scrollRestoration' in window.history)
      ) {
        return;
      }

useEffect only ever runs on the client so that window check is unnecessary (and wrong btw, typeof gives you a string: "undefined")

@joggienl
Copy link

@claus, I am trying this gist and I noticed that you used url in the onRouteChangeComplete handler. As noted in the documentation, the url param also has the basePath (and locale) prepended to it... so for sites with locales setup and maybe even a basePath this doesn't work as expected, because router.asPath does not have basePath set to it.

Also, I do not think you have to import Router because you can set the events and beforePopState directly to the incoming router parameter (seems to work).

@JoeLim13
Copy link

@claus, thanks for the excellent solution. What if I want it to work well with infinite scrolling?

@kikoanis
Copy link

kikoanis commented Nov 6, 2022

Sorry to hijack the gist but I think there is a case where the original code is not taking care of, which is when I click on a Nextjs Link where I actually expect to go to the top of the page (it is not a router pop event) or even I am using browser forward .

Here is my version

/**
 * Based on https://gist.github.com/claus/992a5596d6532ac91b24abe24e10ae81
 * - see https://github.com/vercel/next.js/issues/3303#issuecomment-628400930
 * - see https://github.com/vercel/next.js/issues/12530#issuecomment-628864374
 */
import { useEffect, useState } from 'react';

import Router, { useRouter } from 'next/router';

function saveScrollPos(asPath: string) {
    sessionStorage.setItem(`scrollPos:${asPath}`, JSON.stringify({ x: window.scrollX, y: window.scrollY }));
}

function restoreScrollPos(asPath: string) {
    const json = sessionStorage.getItem(`scrollPos:${asPath}`);
    const scrollPos = json ? JSON.parse(json) : undefined;
    if (scrollPos) {
        window.scrollTo(scrollPos.x, scrollPos.y);
    }
}

export const useScrollRestoration = (): void => {
    const router = useRouter();
    const [shouldScrollRestore, setShouldScrollRestore] = useState(false);

    useEffect(() => {
        if (shouldScrollRestore) {
            restoreScrollPos(router.asPath);
        }
    }, [router.asPath, shouldScrollRestore]);

    useEffect(() => {
        if (!('scrollRestoration' in window.history)) return;
        window.history.scrollRestoration = 'manual';

        const onBeforeUnload = (event: BeforeUnloadEvent) => {
            saveScrollPos(router.asPath);
            delete event['returnValue'];
        };

        const onRouteChangeStart = () => {
            saveScrollPos(router.asPath);
        };

        const onRouteChangeComplete = () => {
            setShouldScrollRestore(false);
        };

        window.addEventListener('beforeunload', onBeforeUnload);
        Router.events.on('routeChangeStart', onRouteChangeStart);
        Router.events.on('routeChangeComplete', onRouteChangeComplete);
        Router.beforePopState(() => {
            setShouldScrollRestore(true);
            return true;
        });

        return () => {
            window.removeEventListener('beforeunload', onBeforeUnload);
            Router.events.off('routeChangeStart', onRouteChangeStart);
            Router.events.off('routeChangeComplete', onRouteChangeComplete);
            Router.beforePopState(() => true);
        };
    }, [router, shouldScrollRestore]);
};

@CharliePops
Copy link

Thanks you!

@sjns19
Copy link

sjns19 commented Nov 10, 2022

Hi, it seems to be working a weird way for me. I have the posts list fetched using getServerSideProps, my expected behaviour is, when I click one of the posts and I hit the back button on the browser, the scroll should be restored to where I was before but it's working on initial page load only then stops working even if I refresh the entire page, I have to clear the browsing data/cache in order for it to work again but the result is the same. It seems to be working fine on pages that don't deal with data fetching but static texts/images though which I assume Next handles by default. Any clue?

@kikoanis
Copy link

@sjns19 There are different reasons for this. Does the props that are fetched using getServerSideProps cached and reused on client side or data is refetched on client side. Which means if they refetched, screen will lose the actual position because it won't be available on the first rendering on navigating back.

Without actual reproduction code, I cannot tell.

@sjns19
Copy link

sjns19 commented Nov 12, 2022

@sjns19 There are different reasons for this. Does the props that are fetched using getServerSideProps cached and reused on client side or data is refetched on client side. Which means if they refetched, screen will lose the actual position because it won't be available on the first rendering on navigating back.

Without actual reproduction code, I cannot tell.

I figured it works with prod but not dev environment. Probably has something to do with Next's hot reload thingy, my bad.

@somedaycode
Copy link

Thank you!!
This is very helpful!

@alivtar
Copy link

alivtar commented Jan 11, 2023

Thanks for this gist!
I have got an issue with dynamic data.
I have a long list of data. so scrolling happens before the data is fetched completely from API. how can I delay the scrolling and perform it when the UI is rendered?

@kikoanis
Copy link

@alivtar Not sure if it is a good idea to refetch data on going back or even forward. The idea is that this data should be fetched from cache on going back. Otherwise, there is no really a good solution for this.

@digitaltim-de
Copy link

Thanks for this gist! Save my life!

@jvandenaardweg
Copy link

jvandenaardweg commented Jan 19, 2023

Just here to say NextJS has an experimental feature scrollRestoration which seems to work smoothly

In your next.config.js file:

module.exports = {
  experimental: {
    scrollRestoration: true,
  },
};

@Fenny1909
Copy link

Thank you, it worked for me!

@tresorama
Copy link

tresorama commented Feb 2, 2023

Thanks for this gist.

I used it as starting point for this NPM package, use-next-navigation-event.

The package does not contain "scroll" related code but can be used to add a scroll restoration strategy without dealing with next/router.
The package lets you run arbitrary code on "next.js" ruoter navigation event, so you can use it to create a scroll restoration custom flow logic.

Next router gives this on navigation:

newUrl: "....",

This package add these:

newUrl:"...",
oldUrl: "...",
navigationType: "REGULAR_NAVIGATION" | "BACK_OR_FORWARD",

You can use it for whatever you like, here it is a a copy-pastable scroll restoration that uses this package.

NPM Package: https://www.npmjs.com/package/use-next-navigation-event
Docs: https://www.npmjs.com/package/use-next-navigation-event
Repo: https://github.com/tresorama/use-next-navigation-event

@claus
Copy link
Author

claus commented Feb 2, 2023

I used it as starting point for this NPM package, use-next-navigation-event.

@tresorama Nice!

@hamitaksln
Copy link

experimental: {
    scrollRestoration: true,
  },

Thanks a lot, this works great for me.

@michaeljscript
Copy link

michaeljscript commented Apr 27, 2023

I've experienced a few issues with the suggested solutions, so I've built this library https://www.npmjs.com/package/next-restore-scroll-position which fixes the following.

  1. Fixes a bug where the position was restored after page refresh, as this was not the desired behavior
  2. Fixes a bug where the position was not restored because useEffect was called multiple times (my repo uses useRef instead of local variable shouldScrollRestore to solve the issue. useRef is also better than useState because useRef does not rerender components after an update)
  3. Removes the scroll position from storage after the position is restored to stop a memory leak

Example

yarn add next-restore-scroll-position
import { useScrollRestoration } from 'next-restore-scroll-position';

function App() { // This needs to be NextJS App Component
    const router = useRouter();
    useScrollRestoration(router);
}

👍 ✅

@joshua-isaac
Copy link

this is great, thanks!

@PranoySarker
Copy link

It works fine for me
Thank you for this resource

@shawnCaza
Copy link

The hook version from @MichalSzorad seems to work fairly nicely.

I'm not sure if there's a possible solution, that would be worth the complexity, but one scenario I don't see addressed is as follows:

  1. Scroll down page A click on link to page B
  2. On page B click on a link that goes back to page A
  3. We land and stay at the top of page A
  4. Click back button twice, scroll is restored to the scrolled down positions, as it was on first view of page A
  5. Click forward button twice, scroll is again restored to the scrolled down position, even though we had not originally scrolled this second instance of Page A.

How crazy/possible is it to somehow record the scroll position for each pop state version of a url? If we don't, is it better to reset a saved scroll position after arriving to the page via a regular link again?

@Amit-5111998
Copy link

const Feed = ({ navigationList }) => {
const router = useRouter();
useScrollRestoration(router);
}
I have a one-page feed should I send a router like this in the scroll ? @claus @pwfisher

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment