# Next.js Smooth Scrolling with Lenis & GSAP | 2026 Guide

> Updated 2026 guide: smooth scrolling in Next.js App Router with Lenis 1.x and GSAP - current package names, lenis/react, useGSAP, and parallax.

- **Source:** DevDreaming (https://devdreaming.com)
- **Canonical URL:** https://devdreaming.com/blogs/nextjs-smooth-scrolling-with-lenis-gsap
- **Author:** CodeBucks
- **Published:** 2023-12-05
- **Last updated:** 2026-09-28
- **Topics:** User Experience, Next Js

---

![Next.js Smooth Scrolling with Lenis & GSAP | 2026 Guide](https://assets.tina.io/36be67fe-e712-4f9e-83b1-afd64b852422/blogs/Nextjs%20smooth%20scrolling%20with%20parallax%20effect.jpg)

> **Implementation updated April 2026; guidance reviewed September 2026: Lenis 1.x + Next.js 15/16 App Router.**
>
>  The
>
> `@studio-freight/react-lenis`
>
>  and
>
> `@studio-freight/hamo`
>
>  packages were retired when Studio Freight became Darkroom Engineering. This guide uses the
>
> `lenis`
>
>  package (
>
> `lenis/react`
>
>  import), removes the deprecated
>
> `smoothTouch`
>
>  option, and replaces the old
>
> `useWindowSize`
>
>  import with a small hand-rolled hook. The examples target Next.js 15/16 App Router, Lenis 1.3.x, and GSAP 3.12+.

To add **Lenis smooth scrolling in a Next.js App Router** project, install `lenis`, create a client component that renders `<ReactLenis root>` around its children, and use that component to wrap `{children}` in `app/layout.jsx`. The [Lenis setup](#implementing-smooth-scroll-with-lenis) below shows the code. You only need `gsap` and `@gsap/react` if you also want the parallax and `ScrollTrigger` examples.

Lenis controls the scrolling feel; GSAP's `ScrollTrigger` can animate elements based on scroll position. This guide starts with the Lenis setup, then adds parallax and explains how to keep the two in sync. If smooth scrolling is all you need, you can stop after the Lenis section.

Here is the demo we are going to build: [Smooth scroll in Next.js](https://smooth-scroll-next-js.vercel.app/)

If you prefer watching over reading, here is the video tutorial:

[▶ Watch the video on YouTube](https://www.youtube.com/watch?v=QNh0MH-G3OM)

### Key Takeaways

- For smooth scrolling alone, install `lenis`; for the full parallax example, install `gsap` and `@gsap/react` too
- Import the React wrapper from `lenis/react`, not `@studio-freight/react-lenis`
- `smoothTouch` is gone - use `syncTouch: true` for touch-device smoothing
- Register `gsap.registerPlugin(ScrollTrigger)` once at the module level
- Use `useGSAP` from `@gsap/react` for automatic cleanup in React
- Respect `prefers-reduced-motion` so people who prefer less animation can use the page comfortably

### What is Smooth Scrolling and Parallax?

> **Smooth Scrolling:**
>
>  Lenis changes how wheel and touch scrolling feels by applying configurable easing to scroll movement. Native scrolling varies across browsers and input devices, so test the result on the devices your visitors use.

Smooth scrolling alone does not directly improve Interaction to Next Paint (INP): [Google's INP guide](https://web.dev/articles/inp) measures clicks, taps, and key presses, not scrolling. Animation code can still compete for main-thread time, so measure the finished page rather than assuming a Core Web Vitals benefit.

> **Parallax Effect:**
>
>  Layers of content move at different speeds as you scroll, creating an illusion of depth. GSAP's
>
> `ScrollTrigger`
>
>  tracks an element's position in the viewport and drives a
>
> `y`
>
>  offset in sync with scroll progress - the result is a 3D feel on a 2D page.

Together, Lenis and GSAP form a well-tested pairing:

- **Lenis** - created by [Darkroom Engineering](https://github.com/darkroomengineering/lenis), replaces the old `@studio-freight/lenis`. Weighs about 3 kB gzipped. Version 1.0 (released September 2024) introduced a cleaner API and removed several legacy options.
- **GSAP 3.12+** - the animation platform powering ScrollTrigger. The free tier covers everything in this guide. The `@gsap/react` package (also free) adds the `useGSAP` hook for React-safe cleanup.

Now let's get started.

### Setting Up the Next.js Environment

Before diving into the implementation, let's set up our Next.js environment. Make sure you have Node.js 18+ installed. Open your terminal and run:

```powershell
npx create-next-app@latest
```

Once you run this command it will ask you a few things - select as follows:

```powershell
√ What is your project named? ... nextjs-smooth-scroll
√ Would you like to use TypeScript? ... No
√ Would you like to use ESLint? ... Yes
√ Would you like to use Tailwind CSS? ... Yes
√ Would you like to use `src/` directory? ... Yes
√ Would you like to use App Router? (recommended) ... Yes
? Would you like to customize the default import alias (@/*)? " No
```

Once it finishes installing dependencies, navigate into your project directory using `cd nextjs-smooth-scroll`. Install all three packages for the full smooth scroll and parallax example:

```powershell
npm install gsap lenis @gsap/react
```

If you only want Lenis smooth scrolling, `npm install lenis` is enough; you can skip the GSAP sections later in this guide.

> **Why these packages?**
>
>
>
> `gsap`
>
>  is the core animation library,
>
> `lenis`
>
>  ships both the vanilla scroll engine and the React wrapper (
>
> `lenis/react`
>
> ), and
>
> `@gsap/react`
>
>  provides the
>
> `useGSAP`
>
>  hook. The old
>
> `@studio-freight/react-lenis`
>
> ,
>
> `@studio-freight/lenis`
>
> , and
>
> `@studio-freight/hamo`
>
>  packages still exist on npm but are no longer maintained - do not use them for new projects.

Now run `npm run dev` to start the development server and open [http://localhost:3000](http://localhost:3000).

### Implementing Smooth Scroll with Lenis

In your Next.js project, create a file named `SmoothScrolling.jsx` in the `components` folder. This component uses `lenis/react` - the official React wrapper that ships inside the `lenis` package since v1.0.

```javascript
"use client";
import { ReactLenis } from "lenis/react";
import "lenis/dist/lenis.css";

function SmoothScrolling({ children }) {
  return (
    <ReactLenis root options={{ lerp: 0.1, duration: 1.5, syncTouch: true }}>
      {children}
    </ReactLenis>
  );
}

export default SmoothScrolling;
```

**Line 1:** We use the `"use client"` directive because Lenis hooks into browser scroll events - this cannot run on the server.

**Line 2:** The import is now `lenis/react`, not the deprecated `@studio-freight/react-lenis`. This subpath export ships inside the single `lenis` package - no separate install needed.

**Line 3:** Importing the Lenis CSS ensures the scroll container is styled correctly (mainly `overflow: hidden` handling). Without it you may see layout jumps on certain browsers.

**Line 7:** We return the `ReactLenis` component with the following options:

- `root` - tells Lenis to control the whole-page scroll via the `<html>` element.
- `lerp: 0.1` - linear interpolation intensity. `0.1` gives a smooth, slightly laggy feel. Lower = smoother. Useful range: `0.05-0.2`.
- `duration: 1.5` - scroll animation duration in seconds. Only applies when `lerp` is not set; since we set `lerp`, this is effectively overridden, but it is harmless to keep for clarity.
- `syncTouch: true` - this is the **Lenis 1.x replacement for the removed **`smoothTouch`. It mirrors the wheel-event smoothing on touch devices. Note: this can be unstable on iOS < 16, so test on real devices before shipping.

> **Lenis 1.x breaking change:**
>
>
>
> `smoothTouch`
>
>  was removed in v1.0. The replacement is
>
> `syncTouch`
>
> . If you copy-paste old tutorials that still pass
>
> `smoothTouch: true`
>
> , Lenis will silently ignore it and touch scrolling will feel native (not smooth). Switch to
>
> `syncTouch: true`
>
> .

Now import `SmoothScrolling.jsx` in your `layout.js` file and wrap it around `{children}`:

```javascript
import { Inter } from "next/font/google";
import "./globals.css";
import SmoothScrolling from "@/components/SmoothScrolling";

const inter = Inter({ subsets: ["latin"] });

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <SmoothScrolling>{children}</SmoothScrolling>
      </body>
    </html>
  );
}
```

Once `SmoothScrolling` is in the layout, every page in your app gets the smooth scroll behavior. Now let's create some content to scroll through. Create `ImageList.jsx` in the components folder:

```javascript
import React from "react";
import Image from "next/image";

const ImageList = () => {
  return (
    <>
      <Image
        src={"https://picsum.photos/600/400?random=1"}
        alt="Image"
        width={600}
        height={400}
        priority
        sizes="50vw"
      />
      <Image
        src={"https://picsum.photos/600/400?random=2"}
        alt="Image"
        width={600}
        height={400}
        priority
        sizes="50vw"
      />
      <Image
        src={"https://picsum.photos/400/600?random=3"}
        alt="Image"
        width={400}
        height={600}
        sizes="50vw"
      />
      <Image
        src={"https://picsum.photos/600/400?random=4"}
        alt="Image"
        width={600}
        height={400}
        sizes="50vw"
      />
      <Image
        src={"https://picsum.photos/600/400?random=5"}
        alt="Image"
        width={600}
        height={400}
        sizes="50vw"
      />
      {/* Add more images if you like */}
    </>
  );
};

export default ImageList;
```

Before rendering these images, add the `picsum.photos` hostname to `next.config.js`:

```javascript
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: "https",
        hostname: "picsum.photos",
      },
    ],
  },
};

module.exports = nextConfig;
```

Now render `ImageList.jsx` in `page.js`:

```javascript
import ImageList from "@/components/ImageList";

export default function Home() {
  return (
    <main className="p-16 xl:p-32 flex flex-col w-full items-center justify-center">
      <ImageList />
    </main>
  );
}
```

That's it - you now have smooth scroll running across the entire page. Scroll the page and feel the difference compared to native browser scroll.

### Adding Parallax Effects with GSAP

Now let's layer in parallax using GSAP's `ScrollTrigger`. Create `Parallax.jsx` in the components folder.

#### The hand-rolled useWindowSize hook

The [React Hooks fundamentals guide](/blogs/react-hooks-fundamental-guide-for-beginners) explains the state-and-effect pattern behind this extraction. You can also use the [Viewport Size Checker](/tools/viewport-size-checker) to confirm the exact browser dimensions while testing the hook and parallax layout at different widths.

The old tutorial imported `useWindowSize` from `@studio-freight/hamo`. That package still exists but is no longer actively maintained. Instead, we'll write a tiny hook ourselves - it's 10 lines and removes a dependency:

```javascript
// hooks/useWindowSize.js
"use client";
import { useState, useEffect } from "react";

export function useWindowSize() {
  const [width, setWidth] = useState(
    typeof window !== "undefined" ? window.innerWidth : 0
  );

  useEffect(() => {
    function handleResize() {
      setWidth(window.innerWidth);
    }
    window.addEventListener("resize", handleResize);
    return () => window.removeEventListener("resize", handleResize);
  }, []);

  return { width };
}
```

**Lines 5-8:** We initialize state with `window.innerWidth` if running in the browser, or `0` on the server (Next.js SSR). This prevents a hydration mismatch.

**Lines 10-14:** We listen for `resize` events and update state. The cleanup function removes the listener when the component unmounts, preventing memory leaks.

#### The Parallax component

Now create `Parallax.jsx`. Note: we register `ScrollTrigger` once at module scope - this is the recommended pattern since GSAP 3.x. Calling `gsap.registerPlugin()` inside a `useEffect` can cause duplicate registrations in React Strict Mode.

```javascript
"use client";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useRef, useEffect } from "react";
import { useGSAP } from "@gsap/react";
import { useWindowSize } from "@/hooks/useWindowSize";

// Register once at module level - safe in Next.js App Router
gsap.registerPlugin(ScrollTrigger, useGSAP);

export function Parallax({ className, children, speed = 1, id = "parallax" }) {
  const trigger = useRef();
  const target = useRef();
  const timeline = useRef();
  const { width: windowWidth } = useWindowSize();

  useGSAP(
    () => {
      const y = windowWidth * speed * 0.1;
      const setY = gsap.quickSetter(target.current, "y", "px");

      timeline.current = gsap.timeline({
        scrollTrigger: {
          id: id,
          trigger: trigger.current,
          scrub: true,
          start: "top bottom",
          end: "bottom top",
          onUpdate: (e) => {
            setY(e.progress * y);
          },
        },
      });
    },
    { scope: trigger, dependencies: [id, speed, windowWidth] }
  );

  return (
    <div ref={trigger} className={className}>
      <div ref={target}>{children}</div>
    </div>
  );
}
```

**Line 9:** `gsap.registerPlugin(ScrollTrigger, useGSAP)` runs once when the module loads. This is the correct Next.js App Router pattern - calling it inside a component or `useEffect` can cause "Plugin already registered" warnings in development.

**Line 17:** We use `useGSAP` from `@gsap/react` instead of a plain `useEffect`. The key benefit: `useGSAP` automatically calls `gsap.context().revert()` when the component unmounts, which kills all ScrollTrigger instances created inside it. With the old `useEffect` approach you had to manually call `timeline?.current?.kill()` - `useGSAP` handles that for you.

**Line 20:** `windowWidth * speed * 0.1` calculates the vertical travel distance for the parallax shift. At `speed=1` and a 1440px screen, the element moves ~144px over the full scroll range. Negative `speed` values reverse direction.

**Line 21:** `gsap.quickSetter` creates a cached setter function for the `y` CSS transform. It is significantly faster than calling `gsap.set()` on every scroll tick because it skips the string parsing overhead.

**Lines 23-32:** A GSAP timeline with a `ScrollTrigger` config:

- `scrub: true` - ties the animation directly to scroll position (no play/pause, just a scrub).
- `start: "top bottom"` / `end: "bottom top"` - the animation runs while the trigger element is in the viewport.
- `onUpdate` - fires on every scroll tick and sets the `y` position based on `e.progress` (0 to 1).

**Line 34:** `{ scope: trigger, dependencies: [...] }` tells `useGSAP` to scope selectors to the trigger element and re-run the effect when `id`, `speed`, or `windowWidth` changes.

#### Respecting prefers-reduced-motion

Users who prefer reduced motion (vestibular disorders, motion sensitivity) should not experience parallax. Add this check inside `useGSAP`:

```javascript
useGSAP(
  () => {
    const prefersReducedMotion = window.matchMedia(
      "(prefers-reduced-motion: reduce)"
    ).matches;

    if (prefersReducedMotion) return; // skip animation entirely

    const y = windowWidth * speed * 0.1;
    const setY = gsap.quickSetter(target.current, "y", "px");

    timeline.current = gsap.timeline({
      scrollTrigger: {
        id: id,
        trigger: trigger.current,
        scrub: true,
        start: "top bottom",
        end: "bottom top",
        onUpdate: (e) => {
          setY(e.progress * y);
        },
      },
    });
  },
  { scope: trigger, dependencies: [id, speed, windowWidth] }
);
```

**Why this matters:** Parallax can be uncomfortable for people with motion sensitivity. The `prefers-reduced-motion` setting lets them ask for less animation; skipping the effect respects that choice. Scrolling itself is not included in Google's INP measurement.

### How to Use the Parallax Component

Now let's wire `Parallax.jsx` into `ImageList.jsx`. Open the file and update it:

```javascript
import React from "react";
import { Parallax } from "@/components/Parallax";
import Image from "next/image";

const ImageList = () => {
  return (
    <>
      <Parallax speed={1} className="self-start">
        <Image
          src={"https://picsum.photos/600/400?random=1"}
          alt="Image"
          width={600}
          height={400}
          priority
          sizes="50vw"
        />
      </Parallax>

      <Parallax speed={-2} className="self-end overflow-hidden">
        <Image
          src={"https://picsum.photos/600/400?random=2"}
          alt="Image"
          width={600}
          height={400}
          priority
          sizes="50vw"
        />
      </Parallax>

      <Parallax speed={0.5} className="self-start">
        <Image
          src={"https://picsum.photos/400/600?random=3"}
          alt="Image"
          width={400}
          height={600}
          sizes="50vw"
        />
      </Parallax>

      <Parallax speed={-1} className="self-end overflow-hidden">
        <Image
          src={"https://picsum.photos/600/400?random=4"}
          alt="Image"
          width={600}
          height={400}
          sizes="50vw"
        />
      </Parallax>

      {/* Add more images with varying speed values */}
    </>
  );
};

export default ImageList;
```

Wrap each `Image` in a `Parallax` component and pass different `speed` values. Positive values scroll the image upward relative to the page; negative values scroll it downward, increasing depth. Adding `overflow-hidden` to the wrapper clips the image as it moves, giving a clean edge.

### GSAP + Lenis Integration: Syncing the Animation Loop

When Lenis and GSAP use separate animation loops, scroll-driven effects may look out of sync. The [official Lenis React integration guide](https://github.com/darkroomengineering/lenis/blob/main/packages/react/README.md#gsap-integration) shows how to drive Lenis from GSAP's ticker so both update on the same loop.

To use that approach, update `SmoothScrolling.jsx`:

```javascript
"use client";
import { ReactLenis } from "lenis/react";
import "lenis/dist/lenis.css";
import { useRef, useEffect } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";

gsap.registerPlugin(ScrollTrigger);

function SmoothScrolling({ children }) {
  const lenisRef = useRef();

  useEffect(() => {
    function update(time) {
      lenisRef.current?.lenis?.raf(time * 1000);
    }

    gsap.ticker.add(update);
    ScrollTrigger.refresh();

    return () => gsap.ticker.remove(update);
  }, []);

  return (
    <ReactLenis
      root
      ref={lenisRef}
      options={{ lerp: 0.1, duration: 1.5, syncTouch: true, autoRaf: false }}
    >
      {children}
    </ReactLenis>
  );
}

export default SmoothScrolling;
```

**Line 8:** `gsap.registerPlugin(ScrollTrigger)` at module scope - runs once, no duplicates.

**Lines 13-19:** We add Lenis's `raf` method to GSAP's ticker so its scroll updates share GSAP's animation loop.

**Line 17:** `time * 1000` - GSAP's ticker passes time in seconds, but Lenis's `raf` expects milliseconds.

**Line 18:** `ScrollTrigger.refresh()` recalculates all trigger positions once Lenis is ready. Without this, triggers calculated before Lenis changes the scroll container can be off.

**Line 26:** `autoRaf: false` - disables Lenis's built-in RAF loop since we're driving it manually via GSAP's ticker.

### Best Practices and Performance Optimization

Smooth scrolling and parallax are powerful but can hurt performance if implemented carelessly. Pair these guardrails with the broader [website performance optimization guide](/blogs/website-performance-optimization-for-loading-website-faster) when you profile images, scripts, and rendering work:

- **Performance Considerations:** Parallax is GPU-accelerated when you animate only `transform` (which GSAP does by default). Never animate `top`, `left`, `margin`, or anything that triggers layout - these force a full reflow on every frame. Stick to `y` (transform) and `opacity`.
- **User Experience:** Keep parallax subtle. A `speed` of `0.5-1.5` is usually enough to create depth without disorienting users. Test on real devices - effects that look smooth at 120 fps on a MacBook Pro can feel choppy on a mid-range Android at 60 fps.
- **Optimize Images and Assets:** Every image in the parallax track is loaded eagerly. Use Next.js `Image` with proper `sizes`, and consider `loading="lazy"` for images below the fold (below the second viewport height).
- **Limit the Number of Animated Elements:** Each `Parallax` wrapper adds a `ScrollTrigger` instance. There is no universal safe trigger count; profile the full page on representative devices, then reduce or disable effects where scrolling becomes choppy.
- **Responsive Design:** `useWindowSize` re-triggers the GSAP effect on resize via the `dependencies` array - but you should also call `ScrollTrigger.refresh()` after any layout shift (font load, image load, accordion expand) to prevent trigger positions from drifting.
- **Accessibility:** Check `prefers-reduced-motion` as shown above and offer a less animated experience to users who request it.

### Conclusion and References

In this guide we've rebuilt a smooth scrolling and parallax setup for Next.js 15/16 App Router using the current, maintained packages: `lenis` (with `lenis/react` imports), `gsap`, and `@gsap/react`. The key migrations from the 2023 version of this post are: dropping `@studio-freight/react-lenis` for `lenis/react`, replacing `smoothTouch` with `syncTouch`, ditching `@studio-freight/hamo` in favor of a hand-rolled `useWindowSize`, and using `useGSAP` for automatic cleanup.

For a smaller React-only implementation using `smooth-scrollbar`, see the [React smooth-scrolling tutorial](/blogs/smooth-scrolling-in-react-js).

For further reading:

- [Lenis Documentation](https://github.com/darkroomengineering/lenis) - Darkroom Engineering's official repo
- [lenis/react README](https://github.com/darkroomengineering/lenis/blob/main/packages/react/README.md) - React wrapper usage
- [GSAP ScrollTrigger Docs](https://gsap.com/docs/v3/Plugins/ScrollTrigger/) - Full ScrollTrigger API reference
- [@gsap/react - useGSAP hook](https://gsap.com/resources/React/) - React + GSAP best practices
- [Next.js Documentation](https://nextjs.org/docs) - App Router reference
- [Web Vitals - INP](https://web.dev/articles/inp) - Which interactions the INP metric measures
- [prefers-reduced-motion (MDN)](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion) - Accessibility reference

### FAQ

#### Does this work with Next.js 16 App Router?

Yes. The setup in this guide is built specifically for the App Router. The `"use client"` directive on `SmoothScrolling.jsx` and `Parallax.jsx` keeps both components in the client bundle, while your pages and layouts can remain Server Components. Lenis and GSAP both require DOM access, so they cannot run as Server Components.

#### Why is GSAP ScrollTrigger glitchy or out of sync with Lenis?

One possible cause is that Lenis and GSAP are updating on separate animation loops. Try the shared ticker pattern shown in the "GSAP + Lenis Integration" section above: set `autoRaf: false` on the `ReactLenis` component and add Lenis's `raf` method to `gsap.ticker`. If trigger positions are wrong after the layout changes, call `ScrollTrigger.refresh()` to recalculate them.

#### Will smooth scroll hurt my Core Web Vitals?

It depends on implementation. Smooth scrolling itself is not measured by INP, but animation work on the main thread can delay the clicks, taps, and key presses that INP does measure. Heavy assets or layout changes can also affect other Core Web Vitals. There is no fixed number of `ScrollTrigger` instances that guarantees good performance. Measure LCP, CLS, and INP on the finished page, and profile scrolling with Chrome DevTools on representative devices.

#### Is Lenis free?

Yes. Lenis is MIT-licensed and completely free. GSAP's core and the ScrollTrigger plugin are also free for most uses under GSAP's standard license (including commercial projects). The `@gsap/react` hook is free as well. The only GSAP features that require a paid "Club GSAP" membership are premium plugins like SplitText and MorphSVG - nothing in this guide requires those.

#### How do I respect prefers-reduced-motion?

Check `window.matchMedia("(prefers-reduced-motion: reduce)").matches` inside your `useGSAP` callback and return early if it is `true`. This skips the ScrollTrigger setup entirely for users who opt out of motion. For the Lenis smooth scroll itself, you can also disable it for reduced-motion users by passing `options={{ lerp: 1 }}` (which makes scroll effectively instant) or by not rendering the `ReactLenis` wrapper at all. The code snippet in the "Respecting prefers-reduced-motion" section above shows both patterns together.

### Thanks For Reading 😄

---

## Related on DevDreaming

- [All Blog Posts](https://devdreaming.com/blogs)
- [Free Developer Tools](https://devdreaming.com/tools)
- [Video Tutorials](https://devdreaming.com/videos)
- [AI Tools for Developers](https://devdreaming.com/ai-tools)

---

_This is the Markdown twin of a page on **DevDreaming** - free developer tutorials, tools, and AI resources. Source of truth: the canonical HTML URL above._