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