← All articles
Smooth Scroll in Next.js with GSAP & Lenis
A practical guide to buttery 60fps smooth scroll in Next.js using Lenis and GSAP ScrollTrigger — setup, scroll-linked animation, and the performance gotchas.

Smooth scrolling is one of those details that separates a generic template from a site that feels crafted. Done well, it makes scroll-linked animation feel weighty and intentional. Done badly, it fights the browser, drops frames, and breaks accessibility. This guide shows how to add smooth scroll to Next.js the way award-winning sites do it — with Lenis for the scroll itself and GSAP ScrollTrigger for animation — including the gotchas nobody mentions.
We'll target the App Router (Next.js 14+), keep it 60fps, and respect
prefers-reduced-motion.
Why Lenis instead of CSS or a scroll library
You have three broad options for smooth scroll, and they are not equal.
| Approach | Feel | Performance | Control |
|---|---|---|---|
scroll-behavior: smooth (CSS) | Only smooths anchor jumps, not the wheel | Great | None |
| Heavy "smooth scrollbar" libraries | Smooth but often janky, hijacks layout | Mixed | Medium |
| Lenis | Natural inertial wheel/touch smoothing | Excellent | Full |
Lenis won the creative-dev community because it normalises the scroll (wheel, trackpad, touch) into a single eased value without faking the scrollbar or breaking native scrolling. It also exposes a per-frame value you can pipe straight into GSAP — which is exactly what you want for scroll-linked animation.
1. Install
npm install lenis gsap
2. A Lenis provider for the App Router
Smooth scroll is a client concern, so wrap your app in a small client component. The key trick for performance: drive Lenis and GSAP from a single requestAnimationFrame loop so they share one time base and never fight.
// app/lenis-provider.tsx
'use client';
import { ReactLenis, type LenisRef } from 'lenis/react';
import { useEffect, useRef } from 'react';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
gsap.registerPlugin(ScrollTrigger);
export function LenisProvider({ children }: { children: React.ReactNode }) {
const lenisRef = useRef<LenisRef>(null);
useEffect(() => {
function raf(time: number) {
lenisRef.current?.lenis?.raf(time);
ScrollTrigger.update();
}
gsap.ticker.add(raf);
gsap.ticker.lagSmoothing(0);
return () => gsap.ticker.remove(raf);
}, []);
return (
<ReactLenis root options={{ autoRaf: false }} ref={lenisRef}>
{children}
</ReactLenis>
);
}
Then mount it in your root layout, inside <body>:
// app/layout.tsx
import { LenisProvider } from './lenis-provider';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<LenisProvider>{children}</LenisProvider>
</body>
</html>
);
}
That's it — you now have inertial smooth scroll across the whole site.
3. Scroll-linked animation with ScrollTrigger
Because Lenis updates ScrollTrigger every frame, your animations are perfectly in sync with the eased scroll. A pinned, scrubbed section looks like this:
'use client';
import { useRef } from 'react';
import { useGSAP } from '@gsap/react';
import { gsap } from 'gsap';
export function Reveal() {
const ref = useRef<HTMLDivElement>(null);
useGSAP(() => {
gsap.to('.panel', {
yPercent: -100,
ease: 'none',
scrollTrigger: {
trigger: ref.current,
start: 'top top',
end: '+=150%',
scrub: true,
pin: true,
},
});
}, { scope: ref });
return <div ref={ref}>{/* .panel children */}</div>;
}
scrub: true ties the animation's progress directly to scroll position, so the eased Lenis
value carries straight into the motion. This is the same technique behind the pinned showcase
on most case-study sites.
4. The performance gotchas
This is where most implementations fall apart. Keep these in mind:
- Animate only
transformandopacity. They run on the compositor and won't trigger layout. Animatingtop,width, ormarginon scroll will tank your frame rate. - One RAF loop. If Lenis runs its own
requestAnimationFrameand GSAP runs its ticker, you get two loops and subtle jitter. The provider above disables Lenis'sautoRafand drives both from GSAP's ticker. - Call
ScrollTrigger.refresh()after layout changes (font load, route change, images settling) so trigger positions stay correct. - Lazy-load below-the-fold work. Smooth scroll makes long pages tempting; keep Core Web Vitals healthy by deferring heavy components.
5. Accessibility: respect reduced motion
Smooth scroll and big scroll animations can cause motion sickness. Always honour the user's
preference — disable the easing and the animations when prefers-reduced-motion: reduce is set:
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// e.g. pass `options={{ smoothWheel: !reduced }}` to Lenis,
// and guard ScrollTrigger animations behind `if (!reduced) { ... }`.
GSAP's gsap.matchMedia() makes this clean — register reduced-motion variants and GSAP swaps
them automatically.
FAQ
Does Lenis hurt SEO or Core Web Vitals?
No. Lenis smooths the visual scroll without blocking the main thread when set up with a single
RAF loop. Content is still server-rendered HTML, so crawling and indexing are unaffected. Keep
your animations on transform/opacity and CLS/INP stay healthy.
Does it work with the Next.js App Router and SSR?
Yes. The provider is a client component, so it hydrates on the client while your pages stay server-rendered. Mount it once in the root layout.
How do I anchor-scroll to a section?
Use lenis.scrollTo('#section') (or a numeric offset). Lenis intercepts the jump and eases to
the target, so in-page navigation matches the rest of the experience.
Smooth scroll is a small amount of code with an outsized impact on perceived quality. Pair Lenis with GSAP, keep everything on the compositor, drive it from one loop, and respect reduced motion — and you get the kind of weighty, intentional feel that makes people stay.
Want this built into your product? See how I work with teams.