How to Fix: Scroll Position Issue When Changing Pages
When a page change leaves users stranded at the wrong scroll position, the app feels broken even though routing technically works. In this case, the issue happens because navigation preserves the previous page’s scroll state instead of resetting it when the next route renders, which is especially noticeable when the viewport height or browser zoom makes the page partially scrollable.
The reproduction shared in the GitHub repository shows a classic client-side navigation problem: the router changes the page, but the browser does not always return to the top of the document the way users expect. This is common in single-page applications and frameworks that rely on client-side routing.
Understanding the Root Cause
In traditional full-page navigation, the browser reloads the document and naturally starts from the top unless scroll restoration logic says otherwise. In a client-rendered app, route transitions often happen without a full document reload. That means the browser may preserve the previous scroll offset, or the framework may intentionally keep it for performance and navigation continuity.
This bug usually appears when these conditions combine:
- The app uses client-side route transitions.
- The next page renders quickly enough that the old scroll position remains visible.
- No explicit scroll reset runs after navigation.
- Layout height changes due to zoom, responsive breakpoints, or dynamic content.
In practice, when the user clicks to another page, the route updates, but the scroll container stays where it was. If the new page is shorter, taller, or laid out differently, the user lands in the middle or bottom of the content instead of at the top.
Another technical factor is browser scroll restoration. Browsers support a history API feature that can restore scroll state during navigation. In apps with custom routing, that default behavior can conflict with expected UX unless it is deliberately managed.
Step-by-Step Solution
The most reliable fix is to explicitly scroll to the top whenever the route changes. The exact implementation depends on the routing setup, but the pattern is the same: detect a successful navigation event, then call window.scrollTo.
Solution approach:
- Listen for pathname or route changes.
- Run a scroll reset after navigation completes.
- If needed, disable conflicting browser scroll restoration behavior.
If your app is using React-based routing, create a reusable scroll restoration component.
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
export default function ScrollToTop() {
const { pathname } = useLocation();
useEffect(() => {
window.scrollTo({ top: 0, left: 0, behavior: 'auto' });
}, [pathname]);
return null;
}
Then mount it near the router so it runs on every page change:
import { BrowserRouter } from 'react-router-dom';
import ScrollToTop from './ScrollToTop';
import AppRoutes from './AppRoutes';
export default function App() {
return (
<BrowserRouter>
<ScrollToTop />
<AppRoutes />
</BrowserRouter>
);
}
If the repository is using Next.js App Router or Pages Router, the same concept applies, but the hook changes.
For a Next.js App Router client component:
'use client';
import { useEffect } from 'react';
import { usePathname } from 'next/navigation';
export default function ScrollToTop() {
const pathname = usePathname();
useEffect(() => {
window.scrollTo(0, 0);
}, [pathname]);
return null;
}
Mount that component inside your shared layout or top-level page wrapper so it runs consistently across route changes.
If scroll restoration from browser history is interfering, add this once on the client:
import { useEffect } from 'react';
export default function DisableNativeScrollRestoration() {
useEffect(() => {
if ('scrollRestoration' in window.history) {
window.history.scrollRestoration = 'manual';
}
}, []);
return null;
}
Implementation checklist:
- Add a route-aware scroll reset component.
- Place it high enough in the component tree to run on every navigation.
- Use pathname as the dependency so it fires only when the route actually changes.
- Test with different zoom levels and viewport heights.
- Verify behavior for both link clicks and browser back/forward actions.
If your app uses a custom scrollable container instead of the window, scroll that element instead:
useEffect(() => {
const container = document.getElementById('page-container');
if (container) {
container.scrollTo({ top: 0, left: 0, behavior: 'auto' });
}
}, [pathname]);
This distinction matters because many layout systems use overflow containers, and calling window.scrollTo will not affect them.
Common Edge Cases
- Nested scroll containers: The page itself is not scrolling; a wrapper div is. Reset the correct element.
- Hash navigation: If the URL includes an anchor like #section, forcing scroll to top may override expected anchor behavior.
- Back/forward navigation: Users may expect previous scroll position to be restored when using browser history. Decide whether to preserve or reset in those cases.
- Animated page transitions: If scrolling runs before the new page finishes rendering, you may see flicker or incomplete resets.
- Lazy-loaded content: Images, async components, or fetched data can shift layout after the scroll reset, making it look inconsistent.
- Strict Mode in development: Effects can run more than once, which may confuse debugging even if production behavior is fine.
A practical enhancement is to combine scroll reset with route transition completion, especially if the app uses animation libraries or delayed rendering. In those cases, trigger the reset after the new content mounts fully.
FAQ
Why does this only happen at certain zoom levels or window heights?
Because the bug becomes visible only when the page height creates an actual scrollable area. Different zoom levels and viewport sizes change layout flow, making preserved scroll state much easier to notice.
Why is window.scrollTo(0, 0) not always enough?
If the real scrolling happens inside a container with overflow: auto or overflow: scroll, the window is not the element moving. You need to target the correct scroll container instead.
Should I always disable browser scroll restoration?
Not always. If your app needs custom control over route transitions, setting history.scrollRestoration to manual helps avoid conflicts. But if users rely on back/forward restoration, you may want a more nuanced strategy instead of a global override.
For this issue, the cleanest fix is simple: treat scroll position as part of routing UX, not as an accidental browser side effect. Once the app explicitly resets scroll on navigation, page changes behave predictably across viewport sizes, zoom levels, and dynamic layouts.