A great scroll is four layers, not a library
The starting mistake is believing you install “smooth scroll” and the site turns premium. Studio-grade scrolling is four independent mechanics, stacked in this order:
- Inertia: the scroll itself is smoothed, the page trails the wheel slightly.
- Reveal: elements appear as they enter the viewport.
- Pinning: a section holds still while its content advances.
- Scrubbing: an animation, a video or a scene is driven frame by frame by scroll position.
Sites that impress use all four. Sites that make people nauseous use three of them at random, badly tuned, on the same page.
Three sites, three schools
Before writing code, look at what works. These three were inspected directly, not described from memory.
landonorris.com: Webflow, plus an attribute-driven layer
A Webflow site (data-wf-site attributes, assets on the Webflow CDN) with a real engineering layer grafted on top. Lenis is injected through a plain embed carrying its stylesheet (html.lenis, .lenis-smooth [data-lenis-prevent]). More interesting: around twenty Rive <canvas> elements driven purely by HTML attributes such as data-rive-scrolltrigger-target, data-rive-scrolltrigger-start, data-rive-scrolltrigger-end, data-rive-fit and data-rive-input.
That is the pattern worth stealing: no animation is written in the markup. One script scans the DOM, reads the attributes and wires ScrollTrigger. A designer can add an animation from Webflow without touching a line of JavaScript.
jobs.netflix.com: restraint that scales
Next.js App Router with a LenisProvider component wrapping the app. No 3D, no spectacle: a careers site that has to stay usable. The detail that betrays serious work: the search panel carries data-lenis-prevent="true". That panel has its own internal scroll, so it is excluded from global smoothing. Without the attribute, wheeling inside the panel would move the page behind it. That is what separates a clean integration from a copy-paste of the docs.
rockstargames.com: declarative scroll in React
The served HTML is 3.5KB. The whole site is assembled from dynamically loaded micro-frontends (fifteen independent modules, versioned separately, served from a CDN). Among the declared shared dependencies: React, React Router and framer-motion 12.42.2. No GSAP, no Lenis. Their scroll animations live in React components, through useScroll and useTransform. A completely different approach from the other two, and a perfectly valid one: when the site is a React application maintained by several teams, a declarative animation inside the component ages better than a global script scanning the DOM.
The rule: marketing site or Webflow, use GSAP plus Lenis. React application, use Framer Motion. Mixing both in one project means two competing animation loops and guaranteed judder.
Layer 1: inertia
The most visible layer, and the fastest to get wrong. The principle: the browser stops scrolling natively, you intercept the event and interpolate position every frame.
import Lenis from 'lenis'
import gsap from 'gsap'
import ScrollTrigger from 'gsap/ScrollTrigger'
gsap.registerPlugin(ScrollTrigger)
const lenis = new Lenis({
duration: 1.1, // 0.8 to 1.2. Beyond that the site feels sluggish.
smoothWheel: true,
smoothTouch: false, // leave native scrolling on touch
})
// One clock for the entire site: GSAP's.
lenis.on('scroll', ScrollTrigger.update)
gsap.ticker.add((time) => lenis.raf(time * 1000))
gsap.ticker.lagSmoothing(0)
Those five wiring lines are the heart of the subject. Run Lenis in its own requestAnimationFrame loop and GSAP in another, and the two drift by a frame, which makes every pinned section shake. One clock, always.
The four recurring mistakes:
- Too long a duration. Past 1.2 the site does not feel smooth, it feels unresponsive. Perceived quality comes from responsiveness, not from the trail.
- Stacking it with CSS
scroll-behavior: smooth. The two fight and anchors become unpredictable. Remove the CSS. - Forgetting internally scrollable panels. Modals, menus, dropdowns: each one gets
data-lenis-prevent, exactly like the Netflix careers search panel. - Forcing inertia on mobile. Native touch scrolling is already perfectly locked to the finger. Smoothing it introduces a lag that reads instantly as a bug.
Layer 2: reveals
One file, zero animation inside components. Declare intent in HTML and let the script do the rest:
// One pass, every marked element on the site
ScrollTrigger.batch('[data-anim="fade"]', {
start: 'top 85%',
once: true,
onEnter: (els) => gsap.to(els, {
opacity: 1,
y: 0,
duration: 0.8,
ease: 'expo.out',
stagger: 0.06,
}),
})
The values that work, after a lot of trial: a 12 to 24 pixel offset (more than that becomes obvious), a 0.6 to 0.9 second duration, an expo.out or power3.out ease, a 0.04 to 0.08 second stagger inside a group, and a top 85% start so the animation is already under way by the time the eye lands on it.
once: true is not a detail. An animation that replays every time you scroll back up turns reading into a fairground ride. Reveal once, and only animate headings and section intros, never every paragraph.
One last thing: recalculate positions once fonts have loaded, or every trigger is measured against a layout that no longer exists.
document.fonts.ready.then(() => ScrollTrigger.refresh())
Layer 3: pinning
A section that holds still while its content advances. This is what makes a site feel like a film rather than a page. Two routes: CSS position: sticky, free and unbreakable, or ScrollTrigger’s pin, more powerful but it rewrites the DOM.
Simple rule: if you only need something to stick, use sticky. If you need scroll progress bound to an animation, use pin with scrub. The classic case, a horizontal section:
const panels = gsap.utils.toArray('.panel')
gsap.to(panels, {
xPercent: -100 * (panels.length - 1),
ease: 'none',
scrollTrigger: {
trigger: '.horizontal',
pin: true,
scrub: 0.5, // the lag: this is what smooths it
end: () => '+=' + window.innerWidth * panels.length,
invalidateOnRefresh: true,
},
})
Use scrub: 0.5 rather than scrub: true: the animation catches up with scroll position half a second late, which absorbs wheel jerkiness. It is a one-value setting that changes the entire feel.
On mobile, disable pinning or shorten it drastically. A horizontal section pinned across 400% of viewport height, on a phone, mostly reads as a frozen page.
Layer 4: scrubbing
The layer that genuinely impresses: a video or an animation whose every frame maps to a scroll position. Three techniques, ordered by return on effort.
Video scrubbing
A paused <video> whose currentTime you drive. Far lighter than a 300-PNG sequence, and it is what the big product pages use today.
const video = document.querySelector('[data-scrub-video]')
video.pause()
ScrollTrigger.create({
trigger: '.sequence',
start: 'top top',
end: '+=200%',
pin: true,
scrub: 0.3,
onUpdate: (self) => {
if (video.duration) video.currentTime = self.progress * video.duration
},
})
The trap is the encoding. A normal video only carries a keyframe every couple of seconds, so seeking stutters because the decoder has to rebuild each frame. Re-encode with a keyframe everywhere:
ffmpeg -i source.mp4 -an -vf "scale=1280:-2" -c:v libx264 -crf 26 -g 1
-movflags +faststart scrub.mp4
-g 1 forces every frame to be a keyframe. The file grows by roughly 40%, and the scrub becomes perfectly fluid. Without it the effect is unusable.
Rive
For vector motion nothing else comes close: a few dozen kilobytes, crisp at any resolution, and a state machine you drive from code. That is the choice made on the Lando Norris site, with scroll progress fed straight into the animation inputs. A designer works in the Rive editor, the developer only wires it up.
A 3D scene
Three.js driven by scroll is the tier above, and another budget entirely. Only worth considering when the 3D object is the product itself.
The React variant: declarative scroll
If the project is a React application, there is no need to scan the DOM. Scroll progress becomes a reactive value:
const ref = useRef(null)
const { scrollYProgress } = useScroll({
target: ref,
offset: ['start end', 'end start'],
})
const y = useTransform(scrollYProgress, [0, 1], ['0%', '-25%'])
const smooth = useSpring(y, { stiffness: 120, damping: 20, mass: 0.4 })
return <motion.div ref={ref} style={{ y: smooth }} />
The useSpring wrapped around useTransform plays the role of GSAP’s scrub: it damps the motion instead of pinning it frame-perfect to the wheel. Without it the result feels twitchy and cheap.
Performance rules
A scroll animation gets 16 milliseconds per frame. What blows the budget:
- Animate
transformandopacityonly. Animatingtop,left,width,heightormarginforces a layout recalculation every frame. It is the number one cause of judder. - Use
will-change: transformsparingly, never across dozens of elements: every promoted layer eats video memory. - Never read the DOM inside a loop. One
getBoundingClientRect()per frame cancels the entire benefit. ScrollTrigger caches positions, let it. - One global observer rather than one per component.
- Test on a real mid-range phone, not on your laptop. That is where opinions are formed.
Accessibility, the part everyone skips
For a share of your visitors, motion triggers genuine symptoms. The system announces it, you only have to listen:
const mm = gsap.matchMedia()
mm.add('(prefers-reduced-motion: no-preference)', () => {
// every animation lives in here
})
mm.add('(prefers-reduced-motion: reduce)', () => {
lenis.destroy()
gsap.set('[data-anim]', { opacity: 1, y: 0, clearProps: 'transform' })
})
The key reflex: the reduced version renders the final state immediately. It does not disable the animation and leave elements invisible, which is one of the most common and most damaging bugs out there, a site that is entirely blank for the user. Check too that keyboard navigation still works inside pinned sections, and that anchors land correctly after a reload.
What CSS already does on its own
For simple reveals, JavaScript is no longer mandatory:
@keyframes reveal {
from { opacity: 0; translate: 0 24px; }
to { opacity: 1; translate: 0 0; }
}
.reveal {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 10% cover 35%;
}
Compositor-driven, no JavaScript, no loop cost. Support is still partial, so treat it as progressive enhancement with an @supports check and a visible final state by default. On a site where scroll is not the main event, this is often all you need, and it saves loading 50KB of library for three fades.
Wiring the prompt into Claude Code or Codex
The prompt at the top of this page is not meant to be pasted once and forgotten. It is a constraints file: it lives at the root of the project and the agent re-reads it every session. Only the filename changes from one agent to the next.
- Claude Code:
CLAUDE.mdat the repository root. - Codex:
AGENTS.md, character for character the same content. That is the convention it looks for. - Both on one project: keep
AGENTS.mdas the single source and reduceCLAUDE.mdto one import line,@AGENTS.md. One version to maintain instead of two that drift apart within a week. - Plain chat, no agent (Claude or ChatGPT in the browser): paste the prompt, then send the file for the section you are working on. Without repository context, ask for one block of code at a time.
Two habits that hold whatever the agent. First: one layer at a time, with a review between each, never all four at once. Second: make it describe in three bullets what it is about to write before it writes a single line. Correcting a description costs ten seconds, correcting three hundred lines costs thirty minutes.
One limitation worth knowing: with no screenshot tool wired in, neither of them can see its own render. The numeric values in this guide (an 85% start, 0.8 seconds, expo.out, scrub: 0.5) exist precisely for that reason. They stand in for the eye until you put a visual loop in place.
The pre-launch checklist
- One animation loop, GSAP’s, with Lenis wired into it.
data-lenis-preventon every internally scrollable area.ScrollTrigger.refresh()after fonts and images have loaded.- Reveals set to
once: true, limited to headings and section intros. - Pinning disabled or shortened below 768 pixels.
- Scrub videos re-encoded with
-g 1. - The
prefers-reduced-motionbranch actually tested, with the OS setting on. - Tested on a mid-range phone, not just in the simulator.
- Anchors, back navigation and scroll restoration verified.
Scroll is the only interaction 100% of your visitors will use. It is also the only one they notice when it is bad. Three days spent tuning it show up more than a month spent on a feature nobody opens.