← All articles
GSAP ScrollTrigger Tutorial: Pin, Scrub & Parallax
Learn GSAP ScrollTrigger by building the three patterns behind award-winning sites — pinned sections, scrubbed timelines, and parallax — in React/Next.js.

GSAP ScrollTrigger is the single most useful
tool for scroll-driven motion on the web. Once you understand three patterns — pin, scrub,
and parallax — you can recreate most of the scroll effects you see on award-winning sites.
This GSAP ScrollTrigger tutorial builds all three in React/Next.js, and explains the
start/end syntax that trips everyone up.
If you also want buttery scrolling underneath these effects, pair this with smooth scroll using Lenis — the two are designed to work together.
Setup
npm install gsap @gsap/react
In React, always animate inside useGSAP so tweens are scoped and cleaned up automatically:
'use client';
import { useRef } from 'react';
import { useGSAP } from '@gsap/react';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
gsap.registerPlugin(useGSAP, ScrollTrigger);
Understanding start and end
Every ScrollTrigger has a start and end, each written as "[trigger] [scroller]":
"top bottom"→ when the top of the trigger hits the bottom of the viewport."top center"→ top of the trigger reaches the vertical center."bottom top"→ bottom of the trigger reaches the top of the viewport.
You can also use offsets and relative values: start: 'top 80%', end: '+=600' (600px of scroll
after the start). Add markers: true while developing — it draws the trigger positions on screen
and removes 90% of the guesswork.
Pattern 1 — Pin a section
Pinning fixes an element in place while the page keeps scrolling — the foundation of "sticky" storytelling sections.
function Pinned() {
const ref = useRef<HTMLDivElement>(null);
useGSAP(() => {
ScrollTrigger.create({
trigger: ref.current,
start: 'top top',
end: '+=100%', // stay pinned for one viewport of scroll
pin: true,
});
}, { scope: ref });
return <section ref={ref} className="h-screen">Pinned content</section>;
}
end: '+=100%' keeps it pinned for one extra viewport height. Increase it for a longer "hold".
Pattern 2 — Scrub a timeline
scrub ties an animation's progress to the scrollbar, so the user scrubs the motion by
scrolling. Combine it with pin and you get the classic pinned-and-animated section.
function ScrubReveal() {
const ref = useRef<HTMLDivElement>(null);
useGSAP(() => {
const tl = gsap.timeline({
scrollTrigger: {
trigger: ref.current,
start: 'top top',
end: '+=150%',
scrub: 1, // 1 = smooth catch-up; true = locked to scroll
pin: true,
},
});
tl.from('.headline', { yPercent: 100, opacity: 0 })
.to('.bg', { scale: 1.2, ease: 'none' }, 0);
}, { scope: ref });
return <section ref={ref}>{/* .headline, .bg */}</section>;
}
scrub: 1 adds a one-second smoothing so the animation glides instead of snapping — this is the
trick that makes scrubbed sections feel premium.
Pattern 3 — Parallax
Parallax just means layers moving at different speeds. With scrub, it's a one-liner per layer:
useGSAP(() => {
gsap.to('.layer-back', {
yPercent: -30,
ease: 'none',
scrollTrigger: { trigger: '.scene', start: 'top bottom', end: 'bottom top', scrub: true },
});
}, { scope: ref });
Move the background layer less than the foreground (-30 vs -60) and the depth illusion appears.
Always parallax with yPercent/transform — never top/margin — so it stays on the compositor.
Performance & cleanup
A few rules keep ScrollTrigger smooth and bug-free:
| Do | Why |
|---|---|
Animate transform / opacity only | Stays on the GPU compositor; no layout thrash |
Use useGSAP({ scope }) | Auto-reverts tweens + triggers on unmount (no leaks) |
Call ScrollTrigger.refresh() after layout shifts | Fonts/images change element positions |
Set invalidateOnRefresh: true for vw/vh values | Recomputes on resize |
In the App Router, ScrollTrigger lives in client components only. Register the plugin once, and
let useGSAP's scope handle cleanup across route changes.
FAQ
When should I use scrub: true vs scrub: 1?
true locks the animation 1:1 to the scrollbar (precise, can feel rigid). A number adds that many
seconds of smoothing (scrub: 1 is the sweet spot for most reveals).
Why is my pinned section jumping?
Almost always a layout shift after ScrollTrigger measured the page (late fonts, images without
dimensions, or a smooth-scroll library not wired in). Set image sizes and call
ScrollTrigger.refresh() once things settle.
Does ScrollTrigger work with smooth scroll?
Yes — drive both from one RAF loop and call ScrollTrigger.update() each frame. See the
smooth scroll guide for the exact setup.
Pin, scrub, and parallax are the three primitives behind almost every scroll experience worth
copying. Master the start/end syntax, keep everything on transform, and you can build
scroll stories that feel hand-crafted. Want this level of motion in your product?
See how I work with teams.