> **Updated April 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 current `lenis` package (`lenis/react` import), removes the deprecated `smoothTouch` option, and replaces the old `useWindowSize` import with a small hand-rolled hook. All code is tested against Next.js 15/16 App Router, Lenis 1.3.x, and GSAP 3.12+.
In an era where polished, interactive web pages are table stakes, smooth scrolling and parallax effects are two of the most impactful techniques you can add to a Next.js project. The combination of **Lenis** (a ~3 kB scroll library from Darkroom Engineering) and **GSAP** (GreenSock's animation platform) is the go-to approach for production sites — Lenis handles the momentum and easing, GSAP's `ScrollTrigger` handles the positional math. According to Google's Web Vitals research, scroll-jank is one of the top sources of poor Interaction to Next Paint (INP) scores, so getting this right is both a UX and an SEO win.
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:
<YouTube vid="QNh0MH-G3OM" />
> **Key Takeaways**
> - Install one package: `npm install gsap lenis` (no more `@studio-freight/*`)
> - Import 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
> - Always respect `prefers-reduced-motion` — it matters for accessibility and Core Web Vitals
## What is Smooth Scrolling and Parallax?
> **Smooth Scrolling:** Native browser scroll is instant — the page jumps to wherever the wheel event lands. Smooth scrolling adds momentum and easing so the page glides to its destination. Lenis achieves this by intercepting wheel/touch events and driving the scroll position itself using linear interpolation (lerp).
It is not just visual: a jank-free scroll directly reduces your INP score, which Google has included as a Core Web Vital since March 2024.
> **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` and run the following command to install the current versions of GSAP and Lenis:
```powershell
npm install gsap lenis @gsap/react
```
> **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.
## 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 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:** Google's CrUX data shows that roughly 25% of users on macOS and iOS enable "Reduce Motion". Failing to respect it is an accessibility issue and, since the motion is scroll-driven, it can also contribute to poor INP scores on those devices.
## 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 (Why You Need the RAF Sync)
By default, Lenis drives scroll position on its own `requestAnimationFrame` loop, and GSAP's `ScrollTrigger` reads `window.scrollY` on its own RAF loop. If they run out of sync, you'll see ScrollTrigger positions jitter by 1–2 frames.
The fix is to run both on GSAP's ticker. Update `SmoothScrolling.jsx`:
```javascript
"use client";
import { ReactLenis, useLenis } 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. This means Lenis's scroll position updates happen on the same frame as GSAP animations, so `ScrollTrigger` always reads the correct scroll offset.
**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. Here are the most important guardrails:
* **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. More than 20–30 active triggers on a single page will noticeably increase frame time. Disable parallax on mobile if the device pixel ratio suggests a low-end GPU: `if (window.devicePixelRatio < 2 && window.innerWidth < 768) return;`.
* **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:** Always check `prefers-reduced-motion` as shown above. This is not optional — it is an accessibility requirement and it directly affects the experience for ~25% of macOS/iOS users.
## 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 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) — Why smooth scroll affects Core Web Vitals
* [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?
The most common cause is that Lenis and GSAP are running on separate `requestAnimationFrame` loops. When Lenis updates `window.scrollY` on its own loop, ScrollTrigger may read the *old* value on its tick, causing a 1–2 frame lag. The fix is the RAF sync 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`. Also call `ScrollTrigger.refresh()` after mount to ensure trigger positions are calculated against the correct scroll container.
### Will smooth scroll hurt my Core Web Vitals?
It depends on implementation. Done correctly — animating only CSS `transform` and `opacity`, respecting `prefers-reduced-motion`, and keeping the number of active triggers below ~30 — smooth scroll has no measurable negative effect on LCP or CLS. It can *improve* INP if it replaces janky native scroll behavior. Done poorly (animating layout properties, too many triggers, heavy JS on the main thread), it will hurt INP and potentially CLS. Profile with Chrome DevTools Performance panel with CPU 4× slowdown to stress-test on simulated low-end hardware.
### 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 😄