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);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
/#servicesso 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 jobscroll-margin-topdoes 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>
);
}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
/#servicesfrom a shared link gets the browser's native jump on first load, before the smoother exists. If that matters on your site, callsmoother.scrollToforlocation.hashonce, right afterScrollSmoother.create(). - The back button.
pushStaterecords the hash, but going back does not scroll to the previous section on its own. Add apopstatelistener 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#teamfrom 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.