Tutorial

GSAP ScrollTrigger + Lenis: Building Scroll-Driven Animations in Next.js

December 20, 202513 min readby Rahul Patel

How to build smooth scroll-driven animations in Next.js using GSAP ScrollTrigger and Lenis. Character reveals, parallax, horizontal scroll, and mobile fallbacks.

GSAPLenisNext.jsAnimationTutorial
Share:

Scroll animations done badly are a performance nightmare and an accessibility problem. Done well, they feel inevitable like the page is telling a story at exactly the pace you're reading it. This is the implementation I use for this portfolio: Lenis for smooth scroll, GSAP ScrollTrigger for all the animation logic.

I'll walk through the setup, four real animation patterns, and the mobile/performance rules that keep it from breaking on slower devices. All code is Next.js App Router with TypeScript.

Why Lenis + GSAP ScrollTrigger

The native browser scroll event is inconsistent across devices and browsers. On desktop Chrome it fires smoothly; on iOS Safari it fires in bursts; on trackpads it behaves differently depending on the OS. Lenis normalizes this: it intercepts the scroll, applies a lerp (linear interpolation) easing, and emits a single consistent scroll event that GSAP can reliably listen to.

GSAP ScrollTrigger is the industry standard for scroll-driven animations. It handles pinning, scrubbing (tying animation progress to scroll position), enter/leave callbacks, and complex timeline orchestration. It's the right tool for this problem nothing else comes close for the kind of precision you need for a storytelling layout.

Note

GSAP is free for portfolios, personal sites, and open source projects. ScrollTrigger is included in the core gsap package as of GSAP 3. You don't need a club membership for either. Only SplitText (premium) needs a license this guide uses Splitting.js (MIT) instead.

Package Setup

bash
npm install gsap lenis splitting
npm install --save-dev @types/splitting

Step 1: Register GSAP Plugins Once, at the App Level

Never register plugins inside components. If a component mounts more than once, you get duplicate registrations and mysterious animation bugs. Register everything in a single config file:

src/lib/gsap-config.ts
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";

// Register plugins once  this is safe to call multiple times (GSAP deduplicates)
gsap.registerPlugin(ScrollTrigger);

// Global defaults  overridable per animation
gsap.defaults({
  ease: "power2.out",
  duration: 0.8,
});

// ScrollTrigger global defaults
ScrollTrigger.defaults({
  markers: process.env.NODE_ENV === "development", // show debug markers in dev only
});

export { gsap, ScrollTrigger };

Import from @/lib/gsap-config everywhere, not directly from gsap. This ensures plugins are always registered.

Step 2: The Lenis Provider

Lenis needs to run in a React context that persists across the app. The right place is the root layout. We also need to connect Lenis to GSAP's ticker so ScrollTrigger reads the Lenis scroll position, not the native one.

src/components/LenisProvider.tsx
"use client";

import { useEffect, useRef } from "react";
import Lenis from "lenis";
import { gsap } from "@/lib/gsap-config";
import { ScrollTrigger } from "gsap/ScrollTrigger";

export function LenisProvider({ children }: { children: React.ReactNode }) {
  const lenisRef = useRef<Lenis | null>(null);

  useEffect(() => {
    const lenis = new Lenis({
      duration: 1.2,         // lerp duration in seconds
      easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)), // exponential ease-out
      orientation: "vertical",
      smoothWheel: true,
      wheelMultiplier: 1,
      touchMultiplier: 2,
    });

    lenisRef.current = lenis;

    // Connect Lenis to ScrollTrigger  critical step
    // Without this, ScrollTrigger reads native scroll position, not Lenis's smoothed one
    lenis.on("scroll", ScrollTrigger.update);

    // Run Lenis inside GSAP's ticker for frame-perfect sync
    const tickerCallback = (time: number) => lenis.raf(time * 1000);
    gsap.ticker.add(tickerCallback);
    gsap.ticker.lagSmoothing(0); // disable GSAP's lag smoothing  Lenis handles this

    return () => {
      lenis.destroy();
      gsap.ticker.remove(tickerCallback);
    };
  }, []);

  return <>{children}</>;
}
src/app/layout.tsx
import { LenisProvider } from "@/components/LenisProvider";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <LenisProvider>
          {children}
        </LenisProvider>
      </body>
    </html>
  );
}

Step 3: The useGsap Hook React Cleanup

GSAP animations created in React components must be cleaned up on unmount. If you don't kill ScrollTriggers, they pile up as components remount (especially in Strict Mode's double-invoke), causing animation glitches and memory leaks.

src/hooks/useGsap.ts
import { useEffect, useRef } from "react";
import { gsap } from "@/lib/gsap-config";

type GsapContextCallback = (context: gsap.Context) => void;

export function useGsap(
  callback: GsapContextCallback,
  deps: React.DependencyList = []
) {
  const contextRef = useRef<gsap.Context | null>(null);

  useEffect(() => {
    // gsap.context() scopes all animations created inside the callback
    // context.revert() undoes all of them on cleanup
    contextRef.current = gsap.context(() => {
      callback(contextRef.current!);
    });

    return () => {
      contextRef.current?.revert();
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, deps);
}

Animation 1: Character-by-Character Text Reveal

Splitting.js wraps each character in a <span> and sets a --char-index CSS custom property. GSAP then staggers the reveal by character index.

src/components/effects/TextReveal.tsx
"use client";

import { useRef, useEffect } from "react";
import Splitting from "splitting";
import { gsap } from "@/lib/gsap-config";
import { ScrollTrigger } from "gsap/ScrollTrigger";

interface TextRevealProps {
  children: string;
  as?: keyof JSX.IntrinsicElements;
  className?: string;
  delay?: number;
  stagger?: number;
  triggerOnScroll?: boolean;
}

export function TextReveal({
  children,
  as: Tag = "p",
  className,
  delay = 0,
  stagger = 0.03,
  triggerOnScroll = true,
}: TextRevealProps) {
  const containerRef = useRef<HTMLElement>(null);

  useEffect(() => {
    const el = containerRef.current;
    if (!el) return;

    // Respect prefers-reduced-motion
    const prefersReducedMotion = window.matchMedia(
      "(prefers-reduced-motion: reduce)"
    ).matches;

    if (prefersReducedMotion) {
      gsap.set(el, { opacity: 1 });
      return;
    }

    // Split text into character spans
    const results = Splitting({ target: el, by: "chars" });
    const chars = results[0]?.chars ?? [];

    // Start invisible
    gsap.set(chars, { opacity: 0, y: 12 });

    const animationProps = {
      opacity: 1,
      y: 0,
      stagger,
      delay,
      ease: "power2.out",
      duration: 0.5,
    };

    let trigger: ScrollTrigger | undefined;

    if (triggerOnScroll) {
      gsap.to(chars, {
        ...animationProps,
        scrollTrigger: {
          trigger: el,
          start: "top 85%",
          toggleActions: "play none none none",
        },
      });
    } else {
      gsap.to(chars, animationProps);
    }

    return () => {
      trigger?.kill();
      // Restore original HTML (Splitting adds spans  clean up on unmount)
      if (el) el.innerHTML = children;
    };
  }, [children, delay, stagger, triggerOnScroll]);

  return (
    // @ts-expect-error  dynamic tag
    <Tag ref={containerRef} className={className}>
      {children}
    </Tag>
  );
}
Tip

The --char-index CSS variable Splitting.js sets on each span lets you do pure-CSS staggered animations too: animation-delay: calc(var(--char-index) * 30ms). Useful when you want the effect without GSAP loaded.

Animation 2: Reusable Fade-In on Scroll

The most common animation in any scroll-driven layout. Wrap any element in this component and it fades in when it enters the viewport.

src/components/effects/FadeInOnScroll.tsx
"use client";

import { useRef, useEffect } from "react";
import { gsap } from "@/lib/gsap-config";
import { ScrollTrigger } from "gsap/ScrollTrigger";

interface FadeInOnScrollProps {
  children: React.ReactNode;
  className?: string;
  y?: number;         // initial y offset in pixels (default: 24)
  duration?: number;  // seconds
  delay?: number;     // seconds
  once?: boolean;     // if true, don't re-animate on scroll up/down
}

export function FadeInOnScroll({
  children,
  className,
  y = 24,
  duration = 0.7,
  delay = 0,
  once = true,
}: FadeInOnScrollProps) {
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;

    const prefersReducedMotion = window.matchMedia(
      "(prefers-reduced-motion: reduce)"
    ).matches;

    if (prefersReducedMotion) return;

    gsap.set(el, { opacity: 0, y });

    const st = ScrollTrigger.create({
      trigger: el,
      start: "top 88%",
      onEnter: () => {
        gsap.to(el, { opacity: 1, y: 0, duration, delay, ease: "power2.out" });
      },
      onLeaveBack: () => {
        if (!once) gsap.set(el, { opacity: 0, y });
      },
    });

    return () => {
      st.kill();
      gsap.set(el, { clearProps: "all" });
    };
  }, [y, duration, delay, once]);

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

Animation 3: Horizontal Scroll Section

Pin the container, then use scrub to tie the horizontal translation to the user's scroll position. The key is calculating the total scroll distance correctly it's container.scrollWidth - viewport.width.

src/components/HorizontalScrollSection.tsx
"use client";

import { useRef, useEffect } from "react";
import { gsap } from "@/lib/gsap-config";
import { ScrollTrigger } from "gsap/ScrollTrigger";

interface HorizontalScrollSectionProps {
  children: React.ReactNode;
  className?: string;
}

export function HorizontalScrollSection({
  children,
  className,
}: HorizontalScrollSectionProps) {
  const wrapperRef = useRef<HTMLDivElement>(null);
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const wrapper = wrapperRef.current;
    const container = containerRef.current;
    if (!wrapper || !container) return;

    // Disable horizontal scroll on mobile  unreliable on touch
    const isMobile = window.innerWidth < 768;
    if (isMobile) return;

    const prefersReducedMotion = window.matchMedia(
      "(prefers-reduced-motion: reduce)"
    ).matches;
    if (prefersReducedMotion) return;

    // Calculate how far to scroll horizontally
    const getScrollAmount = () => -(container.scrollWidth - window.innerWidth);

    const st = gsap.to(container, {
      x: getScrollAmount,    // function-based value  recalculated on resize
      ease: "none",          // linear  scrub handles the easing
      scrollTrigger: {
        trigger: wrapper,
        start: "top top",
        end: () => `+=${container.scrollWidth - window.innerWidth}`,
        pin: true,           // pin the wrapper while scrolling horizontally
        scrub: 1,            // 1 second smoothing between scroll position and animation
        anticipatePin: 1,    // reduces pin jump on fast scroll
        invalidateOnRefresh: true, // recalculate on window resize
      },
    });

    return () => {
      st.scrollTrigger?.kill();
      gsap.set(container, { clearProps: "x" });
    };
  }, []);

  return (
    // wrapper is the pinned element  it takes up scroll space
    <div ref={wrapperRef} className="overflow-hidden">
      {/* container is what actually moves */}
      <div
        ref={containerRef}
        className={`flex will-change-transform ${className ?? ""}`}
        style={{ width: "max-content" }}
      >
        {children}
      </div>
    </div>
  );
}
Warning

Never use pin: true on mobile. Pinning requires knowing the container height at the time of pin on mobile with dynamic viewport heights (iOS URL bar), this breaks. Use a vertical fallback layout below 768px and call ScrollTrigger.refresh() after any dynamic content loads.

Animation 4: Parallax Layers

Parallax creates depth by moving elements at different speeds relative to scroll. The safest implementation uses yPercent it avoids layout thrashing and only animates transform, which the browser can handle on the compositor thread.

src/components/effects/ParallaxLayer.tsx
"use client";

import { useRef, useEffect } from "react";
import { gsap } from "@/lib/gsap-config";
import { ScrollTrigger } from "gsap/ScrollTrigger";

interface ParallaxLayerProps {
  children: React.ReactNode;
  speed?: number;    // 0 = no parallax, 1 = full scroll speed, -1 = opposite direction
  className?: string;
}

export function ParallaxLayer({
  children,
  speed = 0.3,
  className,
}: ParallaxLayerProps) {
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;

    const prefersReducedMotion = window.matchMedia(
      "(prefers-reduced-motion: reduce)"
    ).matches;
    if (prefersReducedMotion) return;

    // Reduce parallax on smaller screens  less visual impact, better performance
    const isMobile = window.innerWidth < 768;
    const effectiveSpeed = isMobile ? speed * 0.3 : speed;

    const st = gsap.to(el, {
      yPercent: -(effectiveSpeed * 100),  // negative = moves up as you scroll down
      ease: "none",
      scrollTrigger: {
        trigger: el,
        start: "top bottom",
        end: "bottom top",
        scrub: true,  // boolean scrub = 0 smoothing (perfectly in sync)
      },
    });

    return () => {
      st.scrollTrigger?.kill();
      gsap.set(el, { clearProps: "transform" });
    };
  }, [speed]);

  return (
    <div
      ref={ref}
      className={`will-change-transform ${className ?? ""}`}
    >
      {children}
    </div>
  );
}

Staggered Group Reveals

For grids of cards, stats, or any group of elements that should animate together with stagger:

src/components/effects/StaggerReveal.tsx
"use client";

import { useRef, useEffect } from "react";
import { gsap } from "@/lib/gsap-config";
import { ScrollTrigger } from "gsap/ScrollTrigger";

interface StaggerRevealProps {
  children: React.ReactNode;
  className?: string;
  stagger?: number;   // seconds between each child's animation start
  childSelector?: string; // default: direct children
}

export function StaggerReveal({
  children,
  className,
  stagger = 0.1,
  childSelector = ":scope > *",
}: StaggerRevealProps) {
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;

    const prefersReducedMotion = window.matchMedia(
      "(prefers-reduced-motion: reduce)"
    ).matches;
    if (prefersReducedMotion) return;

    const items = gsap.utils.toArray<Element>(el.querySelectorAll(childSelector));
    gsap.set(items, { opacity: 0, y: 20 });

    const st = ScrollTrigger.create({
      trigger: el,
      start: "top 80%",
      onEnter: () => {
        gsap.to(items, {
          opacity: 1,
          y: 0,
          duration: 0.6,
          stagger: {
            amount: stagger * items.length,
            from: "start",
          },
          ease: "power2.out",
        });
      },
    });

    return () => {
      st.kill();
      gsap.set(items, { clearProps: "all" });
    };
  }, [stagger, childSelector]);

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

ScrollTrigger.refresh() When to Call It

ScrollTrigger calculates positions at registration time. If content shifts after that (lazy-loaded images, fonts finishing load, dynamic data), the trigger positions are wrong. Call ScrollTrigger.refresh() after any of these:

typescript
import { ScrollTrigger } from "gsap/ScrollTrigger";

// After images load
window.addEventListener("load", () => ScrollTrigger.refresh());

// After fonts load
document.fonts.ready.then(() => ScrollTrigger.refresh());

// After a route change in Next.js
// (ScrollTrigger.refresh() in a useEffect with [] dependencies
//  on each page component handles this)
useEffect(() => {
  ScrollTrigger.refresh();
}, []);

Performance Rules The Non-Negotiables

  • Only animate `transform` and `opacity` these run on the compositor thread. Animating top, left, width, height, margin, or padding triggers layout recalculation every frame and will drop to sub-30fps on mid-range phones.
  • `will-change: transform, opacity` on elements that animate tells the browser to promote them to their own layer. Add via CSS class, remove after animation with gsap.set(el, { clearProps: 'will-change' }).
  • Kill all ScrollTriggers on unmount ScrollTrigger.getAll().forEach(st => st.kill()) at component cleanup, or use gsap.context() which does this for you.
  • Never read DOM measurements inside GSAP callbacks getBoundingClientRect() in a ScrollTrigger onUpdate handler runs every frame and causes continuous layout thrashing. Calculate once in useEffect, store in a ref.
  • `scrub: true` vs `scrub: 1` true (or 0) means perfectly in sync with scroll; scrub: 1 means 1 second lag for smoothing. Use true for parallax (should feel responsive), 1 for storytelling sequences (needs smoothing).
  • Batch similar elements gsap.utils.toArray('.fade-item').forEach(...) in one useEffect, not individual useEffect per element. Reduces ScrollTrigger instance count.

prefers-reduced-motion Required, Not Optional

Vestibular disorders affect around 35% of adults over 40. Scroll-driven motion can cause nausea and dizziness. Always implement a reduced-motion fallback.

typescript
// In any animation hook or component:
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

if (prefersReducedMotion) {
  // Make everything visible immediately  skip all animations
  gsap.set(elements, { opacity: 1, y: 0, x: 0, clearProps: "will-change" });
  return; // bail out before setting up ScrollTriggers
}

You can also handle this globally in CSS and let GSAP respect it:

css
@media (prefers-reduced-motion: reduce) {
  /* Override any inline styles GSAP might have set */
  * {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
}

The Complete Setup in Order

  1. 1.Install gsap, lenis, splitting
  2. 2.Create src/lib/gsap-config.ts register ScrollTrigger, set defaults
  3. 3.Create src/components/LenisProvider.tsx init Lenis, connect to GSAP ticker
  4. 4.Wrap root layout with <LenisProvider>
  5. 5.Create src/hooks/useGsap.ts GSAP context + cleanup
  6. 6.Build animation components (TextReveal, FadeInOnScroll, ParallaxLayer, StaggerReveal)
  7. 7.Add ScrollTrigger.refresh() calls after dynamic content loads
  8. 8.Test with Chrome DevTools → Rendering → Emulate CSS media feature → prefers-reduced-motion: reduce
  9. 9.Test horizontal scroll section on mobile verify it falls back to vertical stack
  10. 10.Run Lighthouse Performance score should stay above 90 with animations active
Tip

Enable GSAP's development markers during setup: ScrollTrigger.defaults({ markers: process.env.NODE_ENV === 'development' }). The red/green markers show exactly where your triggers start and end, and save hours of debugging invisible trigger positions.

more from the notebook

March 1, 2026Technical

AsyncLocalStorage in Node.js: The Complete Guide with Real Examples

Everything you need to know about AsyncLocalStorage from basic context tracking to building a full request-scoped loggi…

January 25, 2026Tutorial

Next.js 15 Static Export to GitHub Pages The Complete Guide

Deploy Next.js 15 App Router to GitHub Pages with static export. Covers next.config, GitHub Actions, fonts, images, trai…