-
-
Save claus/992a5596d6532ac91b24abe24e10ae81 to your computer and use it in GitHub Desktop.
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]); | |
} |
Works like a charm, thanks! ❤
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!
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
[],
);
};
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"
)
@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).
@claus, thanks for the excellent solution. What if I want it to work well with infinite scrolling?
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]);
};
Thanks you!
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?
@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 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.
Thank you!!
This is very helpful!
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?
@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.
Thanks for this gist! Save my life!
Just here to say NextJS has an experimental feature scrollRestoration
which seems to work smoothly. That makes this gist obsolete.
In your next.config.js file:
module.exports = {
experimental: {
scrollRestoration: true,
},
};
Thank you, it worked for me!
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
I used it as starting point for this NPM package,
use-next-navigation-event
.
@tresorama Nice!
experimental: { scrollRestoration: true, },
Thanks a lot, this works great for me.
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.
- Fixes a bug where the position was restored after page refresh, as this was not the desired behavior
- Fixes a bug where the position was not restored because
useEffect
was called multiple times (my repo uses useRef instead of local variableshouldScrollRestore
to solve the issue.useRef
is also better thanuseState
becauseuseRef
does not rerender components after an update) - 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);
}
👍 ✅
this is great, thanks!
It works fine for me
Thank you for this resource
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:
- Scroll down page A click on link to page B
- On page B click on a link that goes back to page A
- We land and stay at the top of page A
- Click back button twice, scroll is restored to the scrolled down positions, as it was on first view of page A
- 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?
I encountered an issue with the latest version of Next.js (v15) where the router events are not supported as expected. If you're experiencing similar problems, you might find this component helpful.
This method uses replaceState to store the position within the history API, avoiding reliance on Next.js APIs. As a result, it may also work on older versions, depending on their implementation.
Check out the component here: gist 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:
- Scroll down page A click on link to page B
- On page B click on a link that goes back to page A
- We land and stay at the top of page A
- Click back button twice, scroll is restored to the scrolled down positions, as it was on first view of page A
- 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?
You may want to try the component I provided as it should not have any issues with rapid navigation, or going to urls which were previously visited.
Okay I've just got done doing tons of testing and the solution I've landed on is to use NextJS's experimental support.
experimental: {
scrollRestoration: true,
},
However to fix iOS snapshots you must wrap your app with this:
useEffect(() => {
window.history.scrollRestoration = 'auto'
}, [router])
This only works for pages that are rendered instantly. If you are loading data or the page isn't ready, than you'll need to use one of the solutions above. But make sure you modify it to set scrollRestoration to "auto" so iOS snapshots continue to function normally.
I'm back again. I updated and simplified the above scripts pretty drastically. I'm also using window.history.state.key to track positions, so it supports going to the same page throughout history and always restoring the correct scroll position. This also fixes iOS Snapshots, since I don't set window.scrollRestoration to manual.
I also allow you to set a selector for an element to restore scroll to, for my website I have an "overflow-y-auto" child element that I need scrollRestoration for, so window won't do the trick. Here is my updated, versatile script for 2024 lol.
I also added an option to add a delay if you need to, and I moved router into the options, it will default to useRouter if you don't provide one.
useScrollRestoration.js
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
export const saveScrollPos = (key, selector) => {
let scrollPos = { x: window.scrollX, y: window.scrollY }
if (selector) {
const element = document.querySelector(selector)
scrollPos = { x: element.scrollLeft, y: element.scrollTop }
}
sessionStorage.setItem(`scrollPos:${key}`, JSON.stringify(scrollPos))
}
export const restoreScrollPos = (key, selector) => {
const json = sessionStorage.getItem(`scrollPos:${key}`)
const scrollPos = json ? JSON.parse(json) : { x: 0, y: 0 }
if (selector) {
const element = document.querySelector(selector)
element.scrollTo(scrollPos.x, scrollPos.y)
} else {
window.scrollTo(scrollPos.x, scrollPos.y)
}
}
export const deleteScrollPos = (key) => {
sessionStorage.removeItem(`scrollPos:${key}`)
}
export function useScrollRestoration({ router = null, enabled = true, selector = null, delay = null } = {}) {
const nextRouter = useRouter()
if (!router) {
router = nextRouter
}
const [key, setKey] = useState(null)
useEffect(() => {
setKey(window.history.state.key)
}, [])
useEffect(() => {
if (!enabled) return
const onBeforeUnload = () => {
deleteScrollPos(key)
deleteScrollPos(window.history.state.key)
}
const onRouteChangeStart = () => {
saveScrollPos(key, selector)
}
const onRouteChangeComplete = () => {
setKey(window.history.state.key)
if (delay != null) {
setTimeout(() => {
restoreScrollPos(window.history.state.key, selector, delay)
deleteScrollPos(window.history.state.key)
}, delay)
} else {
restoreScrollPos(window.history.state.key, selector, delay)
deleteScrollPos(window.history.state.key)
}
}
window.addEventListener('beforeunload', onBeforeUnload)
router.events.on('routeChangeStart', onRouteChangeStart)
router.events.on('routeChangeComplete', onRouteChangeComplete)
return () => {
window.removeEventListener('beforeunload', onBeforeUnload)
router.events.off('routeChangeStart', onRouteChangeStart)
router.events.off('routeChangeComplete', onRouteChangeComplete)
}
}, [enabled, key, selector])
}
Default setup:
useScrollRestoration()
Scroll child element <main>
with overflow:
useScrollRestoration(router, { selector: "main" })
Delay Example:
useScrollRestoration(router, { delay: 100 })
@LeakedDave is this supposed to work with app router ?
@AndrejGajdos yeah that's precisely what the
onBeforeLoad
is trying to fix, but unfortunately it has unwanted side effects