Skip to content

Writing

Fixing anchor links under GSAP ScrollSmoother in Next.js

ScrollSmoother translates your page instead of scrolling it, so the browser's own jump to #services lands in the wrong place. One delegated handler fixes it without touching a single link.

By Yash Mittal4 min read

Anchor links break under GSAP ScrollSmoother because it does not scroll the document. It fixes the page inside a wrapper and moves the content with a transform, so the browser's native jump to a hash lands where the section was laid out, not where it is drawn. The fix Shark Labs ships is one delegated click listener that catches in-page links, hands them to `smoother.scrollTo(target, true, position)`, and pushes the hash into history itself so the URL still updates.

This comes from the site we built for Studio Ekatva, an architecture practice whose site uses ScrollSmoother on desktop. The code below is what is running there, not a reconstruction.

Why the native jump lands in the wrong place

ScrollSmoother needs two elements, #smooth-wrapper and #smooth-content. It pins the wrapper to the viewport, sets the body's height so a native scrollbar still exists, and then moves the content with a transform that eases towards wherever the real scroll position is. That easing is the entire effect.

A hash link asks the browser to scroll until the target is at the top. The browser does that against its own idea of the document, which no longer matches what is drawn, because what is drawn is mid-transform inside a fixed box. The result is a jump that lands short, lands long, or appears to do nothing, depending on where you started. scroll-margin-top has the same problem, for the same reason.

The rule

Under GSAP ScrollSmoother, every programmatic scroll has to go through smoother.scrollTo(). That includes hash links, which the browser treats as programmatic scrolls. Shark Labs handles them with one delegated click listener rather than by rewriting each link.

The fix: one delegated click handler

Rather than touching every <a href="#…"> on the site, one listener on document catches clicks, finds the nearest anchor, and takes over only when it points inside the page.

const scrollToAnchor = (event: MouseEvent) => {
  const smoother = ScrollSmoother.get();
  if (!smoother) return; // below 1024px the browser handles it correctly

  const link = (event.target as HTMLElement | null)?.closest("a");
  const targetId = link?.getAttribute("href")?.match(/^\/?#(.+)$/)?.[1];
  if (!targetId) return;

  const target = document.getElementById(targetId);
  if (!target) return;

  event.preventDefault();
  smoother.scrollTo(target, true, ANCHOR_REST_POSITION);
  history.pushState(null, "", `#${targetId}`);
};

document.addEventListener("click", scrollToAnchor);
From the Studio Ekatva `SmoothScroll` component. `ANCHOR_REST_POSITION` is `"top 140px"`, which clears the fixed header.

Four details in there are doing real work:

  • `ScrollSmoother.get()` is the gate. If no smoother exists, the handler returns before touching the event, and the browser's native behaviour takes over. That is what makes this safe to leave attached on mobile.
  • The regex accepts `/#services` as well as `#services`. A shared header usually links to /#services so it also works from other routes. On the home page, that is still an in-page jump.
  • The rest position is a string, not a number. "top 140px" means the target's top comes to rest 140px below the viewport's top, clear of a fixed header. It does the job scroll-margin-top does in native scrolling.
  • `history.pushState` puts the hash back. preventDefault() also cancels the URL update, and a section link that doesn't change the URL can't be copied and shared.

Wiring it into a Next.js client component

The handler lives inside the same effect that creates the smoother. The libraries are imported dynamically inside that effect, so none of GSAP is in the bundle the first paint waits on. Lazy-loading GSAP in Next.js covers that half in detail.

"use client";

const SMOOTH_SCROLL_MEDIA_QUERY = "(min-width: 1024px)";
const ANCHOR_REST_POSITION = "top 140px";

export default function SmoothScroll({ children }: { children: React.ReactNode }) {
  const wrapper = useRef<HTMLDivElement>(null);
  const content = useRef<HTMLDivElement>(null);

  useEffect(() => {
    let isCancelled = false;
    let mediaContext: { revert: () => void } | undefined;
    let detachAnchorHandler: (() => void) | undefined;

    (async () => {
      const [{ gsap }, { ScrollTrigger }, { ScrollSmoother }] = await Promise.all([
        import("gsap"),
        import("gsap/ScrollTrigger"),
        import("gsap/ScrollSmoother"),
      ]);
      if (isCancelled) return;

      gsap.registerPlugin(ScrollTrigger, ScrollSmoother);

      const mediaQueries = gsap.matchMedia();
      mediaQueries.add(SMOOTH_SCROLL_MEDIA_QUERY, () => {
        ScrollSmoother.create({
          wrapper: wrapper.current ?? undefined,
          content: content.current ?? undefined,
          smooth: 1,
          effects: true,
          smoothTouch: 0.1,
        });
      });
      mediaContext = mediaQueries;

      // …scrollToAnchor from above…
      document.addEventListener("click", scrollToAnchor);
      detachAnchorHandler = () => document.removeEventListener("click", scrollToAnchor);
    })();

    return () => {
      isCancelled = true;
      detachAnchorHandler?.();
      mediaContext?.revert();
    };
  }, []);

  return (
    <div id="smooth-wrapper" ref={wrapper}>
      <div id="smooth-content" ref={content}>
        {children}
      </div>
    </div>
  );
}
The rest of the component. `gsap.matchMedia()` owns both the desktop gate and the teardown.

Two things about the effect are easy to get wrong. The isCancelled flag matters because the imports are asynchronous. In development, React Strict Mode mounts, unmounts and remounts every effect, and without the flag the first, discarded run would still create a smoother after its own cleanup had already run. And matchMedia().revert() kills the smoother, so resizing from desktop down to a phone leaves a normally scrolling page rather than a transformed one with no smoother to control it.

Why only desktop

Smoothing a scroll means the page stops following your finger exactly. With a mouse wheel that reads as polish, but with a thumb on a phone it reads as lag, and native momentum scrolling on phones is already smooth. So the smoother is created only at 1024px and up, and below that the handler finds nothing, returns early, and the browser does anchor jumps natively. There, a single :target { scroll-margin-top: 7rem; } rule handles the header offset.

What this handler does not cover

  • Arriving with a hash already in the URL. Someone who opens /#services from a shared link gets the browser's native jump on first load, before the smoother exists. If that matters on your site, call smoother.scrollTo for location.hash once, right after ScrollSmoother.create().
  • The back button. pushState records the hash, but going back does not scroll to the previous section on its own. Add a popstate listener if your sections are deep enough that people navigate between them this way.
  • Links to other pages with a hash. The handler only acts when the target element exists on the current page. /about#team from the home page is left to the router, which is correct.

The short version

ScrollSmoother moves your page with a transform, so anything that scrolls it has to go through smoother.scrollTo(). One delegated click listener that recognises in-page anchors, scrolls to them with a header-sized offset and restores the hash in the URL fixes every link on the site at once. Gate it on ScrollSmoother.get() and it steps aside on its own wherever the smoother does not exist.

This is what we do. How we build fast, motion-heavy websites.

How we build fast, motion-heavy websites

FAQ

Related questions

Do I need a paid GSAP licence for ScrollSmoother?

No. ScrollSmoother was once a members-only Club GSAP plugin, but since GSAP 3.13 every plugin, ScrollSmoother included, ships free in the public gsap package. Import it from gsap/ScrollSmoother like any other plugin. The Studio Ekatva site does exactly that.

Should I use Lenis instead?

If smooth scrolling is the only effect you need, Lenis is lighter and scrolls the real document, so native anchor links and scroll-margin-top keep working. ScrollSmoother earns its place when you also want its effects, such as parallax driven by data-speed attributes, integrated with ScrollTrigger pinning. Pick based on those effects, not on the smoothing.

Keep reading

More on this

Article

How to lazy-load GSAP in Next.js, and keep it out of first paint

A top-level `import gsap` puts the whole animation stack in the bundle your page waits on. Importing it inside the effect that needs it moves it after first paint, and only costs you a few lines.

Next step

Want this applied to your situation?

Articles generalise. A 45-minute call doesn't — tell us what you're actually dealing with and we'll be specific.

Start a projectRead more articles