Smooth Scrolling with Lenis and GSAP ScrollTrigger in Next.js
A small provider that runs Lenis on GSAP's ticker, so smooth scrolling and ScrollTrigger share one clock and pinned sections stop shuddering.
Lenis gives a site that weighted, expensive scroll. ScrollTrigger drives animations from the scroll position. Run them on two different clocks and every scrubbed or pinned section lags a frame behind the content. The fix is to let GSAP's ticker drive Lenis.
The provider
components/smooth-scroll.tsx
"use client";
import { useEffect, type ReactNode } from "react";
import Lenis from "lenis";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
export function SmoothScroll({ children }: { children: ReactNode }) {
useEffect(() => {
if (matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const lenis = new Lenis({ duration: 1.1 });
lenis.on("scroll", ScrollTrigger.update);
const raf = (time: number) => lenis.raf(time * 1000);
gsap.ticker.add(raf);
gsap.ticker.lagSmoothing(0);
return () => {
gsap.ticker.remove(raf);
lenis.destroy();
};
}, []);
return children;
}Wrap your layout with it:
app/layout.tsx
<body>
<SmoothScroll>{children}</SmoothScroll>
</body>Why each line is there
- lenis.on("scroll", ScrollTrigger.update) tells ScrollTrigger the moment the position changes, instead of waiting for a native scroll event.
- gsap.ticker.add runs Lenis on GSAP's frame loop, so both read the same frame.
timearrives in seconds and Lenis wants milliseconds. - lagSmoothing(0) stops GSAP from slowing time down after a dropped frame, which would leave the scroll and the animations out of step.
- prefers-reduced-motion: people who asked for less motion get the browser's native scroll.
Scrollable boxes inside the page
Lenis takes over the mouse wheel for the whole page, so a code block or menu with its own scrollbar stops scrolling. Add data-lenis-prevent to those elements and the wheel scrolls them again.