Routing & UX3 min read

Next.jsAppRouterScroll-to-TopRestoration

By Devansh Khetwani
Published

In standard server-rendered websites, navigating to a new page naturally resets the window scroll position to the top. However, in single-page applications and Next.js App Router applications equipped with virtual smooth scrollers (like Lenis), client-side soft navigations frequently preserve the previous page's scroll offset or produce awkward delayed jumps.

Virtual Scroll Coordinates vs Native Window Offset

The core issue is that virtual scrollers intercept native wheel and touch events, managing their own internal virtual coordinate state while translating the DOM or clamping native scroll. When Next.js executes a client transition via usePathname() or <Link>, the browser's native scroll restoration does not update Lenis's internal virtual accumulator. As a result, the user lands mid-page on the incoming route.

import { useEffect } from "react";
import { usePathname } from "next/navigation";
import { useLenis } from "lenis/react";
import { ScrollTrigger } from "gsap/ScrollTrigger";

export function RouteScrollReset() {
  const pathname = usePathname();
  const lenis = useLenis();

  useEffect(() => {
    // Immediate virtual scroll reset
    if (lenis) {
      lenis.scrollTo(0, { immediate: true });
    } else {
      window.scrollTo(0, 0);
    }

    // Re-calculate ScrollTrigger coordinate offsets for new route content
    const timer = setTimeout(() => {
      ScrollTrigger.refresh();
    }, 100);

    return () => clearTimeout(timer);
  }, [pathname, lenis]);

  return null;
}

Why Immediate Reset and Coordinate Refresh Matter

Passing { immediate: true } to lenis.scrollTo(0) bypasses interpolation tweening, zeroing the scroll accumulator on the exact tick of route unmount. This avoids a disorienting animated scroll back to the top that distracts the user during page entry.

Furthermore, because new route DOM dimensions may not be fully painted immediately upon route trigger, a small delay (100ms) before invoking ScrollTrigger.refresh() gives the App Router layout pass enough time to settle before GSAP measures marker start/end trigger coordinates.