Smooth Scrolling Done Right: Lenis + GSAP ScrollTrigger in Next.js
- GSAP
- Lenis
- ScrollTrigger
- Next.js
- animation
- frontend
Lenis for smooth scrolling plus GSAP ScrollTrigger for scroll-driven animation is the standard pairing on every award-bait site of the last few years. The basic wiring is five lines — and then there are four integration bugs that don't show up until your site is half-built. I hit all four building this portfolio; here's the complete picture.
The canonical wiring
Lenis virtualizes scrolling: it intercepts wheel/touch input and animates scrollTop itself with easing. ScrollTrigger needs to know about every scroll position change, and both libraries want to own the animation frame. The official integration resolves this by making GSAP's ticker the single clock:
"use client";
import { useEffect } from "react";
import Lenis from "lenis";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
export default function SmoothScroll({ children }: { children: React.ReactNode }) {
useEffect(() => {
const lenis = new Lenis({ lerp: 0.12, anchors: true });
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000)); // GSAP time is s, Lenis wants ms
gsap.ticker.lagSmoothing(0);
return () => {
gsap.ticker.remove((time) => lenis.raf(time * 1000));
lenis.destroy();
};
}, []);
return <>{children}</>;
}
Three details people miss: the seconds-to-milliseconds conversion in the ticker callback; lenis.on("scroll", ScrollTrigger.update) so triggers fire during Lenis's eased frames (not just on real input); and lagSmoothing(0) — GSAP's lag compensation "helpfully" jumps time forward after a slow frame, which translates to a visible scroll hitch when the timeline drives scrolling itself.
In Next.js, this is a client component wrapping children in the root layout — mount it once, never per-page.
Bug 1: anchor links tear the page apart
Click <a href="#contact"> and the browser jumps scrollTop instantly — but Lenis's internal animated position still points at the old location. Next frame, Lenis "corrects" the scroll back. The result is a violent jump-and-rewind.
Recent Lenis has anchors: true, which handles same-page anchors. But programmatic navigation — a navbar that scrolls with an offset for its own height — still needs to go through Lenis explicitly. I expose the instance globally and intercept clicks:
// in SmoothScroll: window.__lenis = lenis
function handleNavClick(e: React.MouseEvent, href: string) {
if (href.startsWith("#") && pathname === "/") {
e.preventDefault();
window.__lenis?.scrollTo(href, { offset: -64 });
}
}
The general law: once Lenis owns scrolling, every scroll write must go through Lenis. window.scrollTo, element.scrollIntoView, router scroll restoration — all of them will fight the virtualizer.
Bug 2: your mobile menu scroll-lock is broken
The classic overflow: hidden on <body> doesn't stop Lenis — it animates scrollTop directly and happily scrolls your "locked" page behind the menu overlay. The API exists for exactly this:
useEffect(() => {
menuOpen ? window.__lenis?.stop() : window.__lenis?.start();
}, [menuOpen]);
Same rule applies to modals, drawers, and full-screen lightboxes.
Bug 3: ScrollTrigger measures a page that moved
ScrollTrigger caches trigger positions at setup. Anything that changes layout after that — fonts swapping in, images without dimensions, accordions — silently shifts every trigger below it. Inside React, scope animations with gsap.context and refresh after layout settles:
useLayoutEffect(() => {
const ctx = gsap.context(() => {
gsap.fromTo(el, { y: 48, opacity: 0 }, {
y: 0, opacity: 1,
scrollTrigger: { trigger: el, start: "top 85%", once: true },
});
}, ref);
return () => ctx.revert(); // kills tweens AND triggers — mandatory in Strict Mode
}, []);
ctx.revert() in cleanup is non-negotiable in dev: React 18+ Strict Mode double-invokes effects, and without revert you get duplicated triggers firing twice with offset positions.
One SSR footnote: useLayoutEffect warns on the server. The standard shim picks the right hook per environment:
const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? useLayoutEffect : useEffect;
Bug 4: not everyone wants this
Smooth scroll is an aesthetic choice; for users with vestibular disorders it's a nausea trigger, and they've told you so via prefers-reduced-motion. Respect it at the integration layer, not per-animation — if the user prefers reduced motion, don't instantiate Lenis at all and let scroll-triggered reveals render in their final state. Native scrolling is the feature; your easing curve is the enhancement.
The mental model
Every bug above is the same bug: two systems both believing they own scroll position. Lenis vs. the browser's anchor behavior, vs. your scroll-lock, vs. ScrollTrigger's cached measurements, vs. the user's motion preference. Adopting a scroll virtualizer is an ownership transfer, not a drop-in effect — route everything through it, and the pairing is rock solid.