Motion
Lenis smooth scroll in Next.js with GSAP ScrollTrigger
One provider, one shared clock, and the small details that keep modals, anchors and nested panels working.
By Nabeel Mustafa··6 min read

To set up Lenis smooth scroll in Next.js, wrap your app in ReactLenis from lenis/react with the root prop, inside a client component. If you use GSAP ScrollTrigger, turn off Lenis's own animation loop with autoRaf: false, drive it from the GSAP ticker, and call ScrollTrigger.update on every Lenis scroll. That one shared clock keeps scroll animations in step with the smoothed position.
npm install lenis gsap @gsap/reactThis site uses Lenis with GSAP on every page. Below is the setup, then the details that usually break: anchor links, modals, scrollable panels, reduced motion and touch devices.
A quick note on what Lenis does, because it explains most of the rules below. Lenis does not fake the page with a transformed wrapper. It listens to wheel input, works out a target position, and eases the real window scroll toward it on every frame. So the browser still owns the scrollbar, position: sticky still works, and anything that reads window.scrollY sees the true value.
How to set up Lenis smooth scroll in Next.js
Create one client component that holds the Lenis instance and connects it to GSAP. It keeps a ref to ReactLenis, adds a function to the GSAP ticker that calls lenis.raf(), and removes it on unmount. The GSAP ticker passes time in seconds and Lenis expects milliseconds, so multiply by 1000.
"use client";
import { useEffect, useRef } from "react";
import { ReactLenis, useLenis, type LenisRef } from "lenis/react";
import { gsap, ScrollTrigger } from "@/lib/gsap";
function ScrollTriggerSync() {
// Runs on every Lenis scroll event.
useLenis(() => ScrollTrigger.update());
return null;
}
export default function SmoothScroll({ children }: { children: React.ReactNode }) {
const lenisRef = useRef<LenisRef>(null);
useEffect(() => {
const update = (time: number) => lenisRef.current?.lenis?.raf(time * 1000);
gsap.ticker.add(update);
gsap.ticker.lagSmoothing(0);
return () => gsap.ticker.remove(update);
}, []);
return (
<ReactLenis root ref={lenisRef} options={{ autoRaf: false, lerp: 0.1, anchors: true }}>
<ScrollTriggerSync />
{children}
</ReactLenis>
);
}Then wrap the body content in your root layout. The layout stays a Server Component, only SmoothScroll runs on the client.
import SmoothScroll from "@/components/providers/SmoothScroll";
import "lenis/dist/lenis.css";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<SmoothScroll>{children}</SmoothScroll>
</body>
</html>
);
}The lenis.css file adds the few rules Lenis relies on: it clips overflow while Lenis is stopped and contains overscroll on prevented elements. Also remove any scroll-behavior: smooth from your own CSS. Having both smooth the scroll at once feels sluggish.

Why sync Lenis with the GSAP ticker?
Lenis animates the scroll position toward the target on each frame. ScrollTrigger reads the scroll position on each frame too. If they run on two separate loops, a scrubbed animation can lag one frame behind the scroll, which shows up as a small jitter on pinned sections. Driving Lenis from gsap.ticker puts both on the same frame.
gsap.ticker.lagSmoothing(0) turns off GSAP's lag smoothing, which normally adjusts time after a long frame. With Lenis, you want raw time so the scroll does not jump after a slow frame. Note that it applies to all GSAP animations on the page.
Lenis with root scrolls the real window, so ScrollTrigger needs no scrollerProxy. For the basics of the GSAP side, see how to set up GSAP in the Next.js App Router.
How to handle anchor links and scrollTo
With anchors: true, Lenis catches clicks on same-page hash links like href="#pricing" and scrolls smoothly to the target. You can pass an object instead of true to set options such as an offset for a fixed header, for example anchors: { offset: -80 }.
For buttons, call scrollTo on the instance from useLenis(). It accepts a number, a CSS selector or an element.
"use client";
import { useLenis } from "lenis/react";
export function BackToTop() {
const lenis = useLenis();
return (
<button type="button" onClick={() => lenis?.scrollTo(0, { duration: 1.2 })}>
Back to top
</button>
);
}On a route change in the App Router, the new page should start at the top with no glide. Call lenis.scrollTo(0, { immediate: true, force: true }) when usePathname() changes. force makes it run even if Lenis is stopped. Then refresh ScrollTrigger after the new page has painted, because every trigger on the new page was measured against a layout that may still be settling.
If you also run page transitions, do the reset while the screen is covered, so the jump to the top is never visible. I show that pattern in GSAP page transitions in Next.js.
How to stop scrolling for modals and nested panels
When a modal or full-screen menu opens, the page behind it should not scroll. Call lenis.stop() when it opens and lenis.start() when it closes. This is more reliable than setting overflow: hidden on the body, which Lenis does not watch by default.
const lenis = useLenis();
useEffect(() => {
if (!open) return;
lenis?.stop();
return () => lenis?.start();
}, [open, lenis]);Inside the modal, or in any scrollable panel like a code block, a sidebar or a dropdown list, Lenis would otherwise catch the wheel and scroll the page. Add the data-lenis-prevent attribute to that element, and native scrolling works inside it. There are narrower variants too: data-lenis-prevent-wheel and data-lenis-prevent-touch.
<div className="max-h-[60vh] overflow-y-auto" data-lenis-prevent>
{/* long list */}
</div>Does Lenis work on mobile and with reduced motion?
On touch devices Lenis leaves scrolling to the browser by default, because syncTouch is off. That is what I want. Native touch scrolling on phones already has momentum, and replacing it usually feels worse. ScrollTrigger still works, since the page scrolls normally. Only turn on syncTouch if you have a specific reason and have tested it on real phones.
For reduced motion, keep the provider but stop smoothing the wheel. Lenis recreates its instance when options change, and your page does not remount, because in root mode the provider renders no extra DOM.
const [reduce, setReduce] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
setReduce(mq.matches);
const onChange = () => setReduce(mq.matches);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, []);
// in the JSX
<ReactLenis root ref={lenisRef} options={{ autoRaf: false, anchors: true, smoothWheel: !reduce }}>Smooth scroll is a matter of taste, and for some people it causes discomfort. Respecting the setting costs a few lines. You can also feel the difference between lerp values in my scroll playground before you pick one.
Wrapping up
One ReactLenis root, one shared clock with GSAP, ScrollTrigger.update on every scroll, then stop() and start() for modals, data-lenis-prevent for panels, and native scrolling on touch and for reduced motion. Get those right and smooth scroll stays an improvement instead of a complaint. If you want a scroll experience like this designed and built for your site, see how I work on motion.
Frequently asked questions
Is Lenis better than GSAP ScrollSmoother?
Both are free now. Lenis keeps native scrolling, so position: sticky, the scrollbar and browser find work as usual, and it works with any animation library. ScrollSmoother is built into GSAP and adds effects like data-speed parallax, but it needs a wrapper and content element around your page. On Next.js sites I default to Lenis because it is lighter to adopt.
Does Lenis hurt SEO or Core Web Vitals?
Lenis does not change your HTML, so crawlers see the same content. It adds a small script and runs work on each animation frame while scrolling. That has little effect on loading metrics. What can hurt is heavy scroll-linked animation on top of it, so keep scroll effects to transforms and opacity and test on a mid-range phone.
Why does position sticky not work with Lenis?
With root mode, Lenis scrolls the real window, so position: sticky works. It breaks when Lenis runs in wrapper mode on a custom element, or when a parent has overflow: hidden set. Use the root prop on ReactLenis for page scrolling, and check that no ancestor of the sticky element clips overflow on the scroll axis.
How do I get the current scroll position from Lenis in React?
Pass a callback to useLenis. It runs on every Lenis scroll with the instance, so you can read lenis.scroll, lenis.progress and lenis.velocity. For example, useLenis((lenis) => setProgress(lenis.progress)) drives a progress bar. Calling useLenis() with no callback returns the instance for actions like scrollTo.


