Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
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]);
}
@Franetse7
Copy link

Franetse7 commented Sep 10, 2021

Thanks , congrats

@pwfisher
Copy link

pwfisher commented Dec 6, 2021

Typescript, useScrollRestoration.ts:

/**
 * 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 } from 'react'
import Router, { NextRouter } 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 function useScrollRestoration(router: NextRouter) {
  useEffect(() => {
    if (!('scrollRestoration' in window.history)) return
    let shouldScrollRestore = false
    window.history.scrollRestoration = 'manual'
    restoreScrollPos(router.asPath)

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

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

    const onRouteChangeComplete = (url: string) => {
      if (shouldScrollRestore) {
        shouldScrollRestore = false
        /**
         * Calling with relative url, not expected asPath, so this
         * will break if there is a basePath or locale path prefix.
         */
        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])
}

and ScrollRestorer.ts:

import { FC } from 'react'
import { useRouter } from 'next/router'
import { useScrollRestoration } from './useScrollRestoration'

export const ScrollRestorer: FC = () => {
  const router = useRouter()

  useScrollRestoration(router)

  return null
}

@cristicismas
Copy link

cristicismas commented Jul 20, 2022

Hi! I've just found this and it seems to work for a problem I have with scroll restoration. Is there any particular reason we're not just importing useRouter in the hook file itself, and getting "router" from there? I've made this change in my project and everything seems to work the same, just wondering if there is a problem with this that I'm not seeing. Thanks!

@claus
Copy link
Author

claus commented Jul 20, 2022

@cristicismas Yeah, useRouter should be inside the hook.

@raghurambachu
Copy link

raghurambachu commented Jul 22, 2022

thanks @claus @pwfisher

@boiboif
Copy link

boiboif commented Jul 26, 2022

it worked, thanks @claus @pwfisher

@gambolputty
Copy link

gambolputty commented Aug 16, 2022

The scroll position is restored, when I click the back/forward buttons in my browser. But the scroll position is also restored, when I visit a page the second time by clicking on a link. Is that intended? @claus

Steps:

  1. Click on a link
  2. The new page opens
  3. Scroll down
  4. Click back button in browser
  5. Click the same link (step 1) again
  6. Position is restored (and shouldn't be, because I didn't click forward, but a link?)

@claus
Copy link
Author

claus commented Aug 16, 2022

@gambolputty Removing the onBeforeUnload listener should fix that

@gambolputty
Copy link

gambolputty commented Aug 17, 2022

@claus It did! Thanks for the fix and quick response!

@AndrejGajdos
Copy link

AndrejGajdos commented Aug 18, 2022

@claus @gambolputty when you remove onBeforeUnload there is another strange issue:

  1. Scroll on the page that you want to restore the scrollbar
  2. Open a link
  3. Go back -- scoll position is restored ✓
  4. Scroll on top
  5. Hit refresh -- scroll position is restored, but it should be on top ✘

@claus
Copy link
Author

claus commented Aug 18, 2022

@AndrejGajdos yeah that's precisely what the onBeforeLoad is trying to fix, but unfortunately it has unwanted side effects

@FrozenCaptain
Copy link

FrozenCaptain commented Sep 5, 2022

Works like a charm, thanks!

@mosesoak
Copy link

mosesoak commented Sep 14, 2022

This worked for me, thanks, but I had to add a short setTimeout in the main restore call in onRouteChangeComplete or the page would scroll to the bottom incorrectly, probably because react was still hydrating the dom.

I also wanted to mention that if you turn on smooth scrolling (html { scroll-behavior: smooth; }) forward navigation is problematic as well: it starts the new page scrolled down at the previous position, then animates to top. However that's really just another NextJS problem, not this hook's.

Thanks again for the fix!

@mosesoak
Copy link

mosesoak commented Sep 14, 2022

Here's a very minor update:

  • 1ms timeout to ensure it doesn't run too soon and scroll to bottom of page during initial hydration. (This can cause an unwanted flash of above-the-fold content though, I worked around this by fading above-the-fold page elements in after a tiny delay)
  • Includes sniffing for iOS Safari since manual doesn't work well, as pointed out in the referenced comment
  • Checks that we're not SSR so window isn't accessed on server
  • Hoists shouldScrollRestore to a ref and makes the router input non-reactive, in case hook runs more than once
import Router, { NextRouter } from 'next/router';
import { useEffect, useRef } from 'react';

/**
 * Scroll restoration to work around bugs in NextJS. At time of writing,
 * they do support `experimental: { scrollRestoration: true }` but there
 * are still bugs with it.
 * (https://github.com/vercel/next.js/issues/20951#issuecomment-1247401017)
 *
 * @param router (Non-reactive) NextRouter instance from _app props
 *
 * @example
 * // place in _app.tsx exported component:
 * useScrollRestoration(router);
 *
 * @see https://gist.github.com/claus/992a5596d6532ac91b24abe24e10ae81?permalink_comment_id=4301903#gistcomment-4301903
 * @see https://github.com/vercel/next.js/issues/3303#issuecomment-628400930
 * @see https://github.com/vercel/next.js/issues/12530#issuecomment-628864374
 * @see https://github.com/vercel/next.js/issues/20951#issuecomment-987252541
 */
export const useScrollRestoration = (router: NextRouter) => {
  const shouldScrollRestore = useRef(true);

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

      // Manual doesn't work well on iOS Safari https://github.com/vercel/next.js/issues/20951#issuecomment-1231966865
      const ua = window.navigator.userAgent.toLowerCase();
      const isMobileSafari = /safari/.test(ua) && /iphone|ipod|ipad/.test(ua);
      window.history.scrollRestoration = isMobileSafari ? 'auto' : 'manual';

      const saveScrollPos = (url: string) => {
        sessionStorage.setItem(
          `scrollPos:${url}`,
          JSON.stringify({ x: window.scrollX, y: window.scrollY }),
        );
      };

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

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

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

      /**
       * Calling with relative url, not expected asPath, so this
       * will break if there is a basePath or locale path prefix.
       */
      const triggerRestore = (url: string) => {
        if (shouldScrollRestore.current) {
          // This short delay helps React scroll to correct position after initial hydration
          timer = setTimeout(() => {
            shouldScrollRestore.current = false;
            restoreScrollPos(url);
          }, 1);
        }
      };

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

      // initial load (e.g. page refresh)
      if (shouldScrollRestore.current) {
        triggerRestore(router.asPath);
      }

      return () => {
        clearTimeout(timer);
        window.removeEventListener('beforeunload', onBeforeUnload);
        Router.events.off('routeChangeStart', onRouteChangeStart);
        Router.events.off('routeChangeComplete', triggerRestore);
        Router.beforePopState(() => true);
      };
    },
    // Run only once - inputs can be safely treated as non-reactive
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [],
  );
};

@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")

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