I've built enough animated portfolio sites and agency landing pages at this point that I can usually tell within the first three seconds of scrolling whether a site was built by someone who actually understands scroll animation, or someone who just copied a GSAP tutorial and called it a day.

And honestly, for a long time, I was the second guy.
I remember the first time I tried to recreate one of those Awwwards style hero sections, the ones where text fades and slides as you scroll and everything feels buttery and expensive. I copied the GSAP code almost exactly from a tutorial. Same triggers, same easing, same everything. On my laptop, using my trackpad, it looked incredible. I was proud of it. Then I opened it on my client's Windows machine with a regular mouse, and it looked like it was having a seizure. Stuttering, jumping, completely different animation than what I built. That was the moment I realized the problem was never really the animation. The problem was what the animation was reading from.
That thing is scroll. And native browser scroll is honestly kind of a mess.
Why native scroll ruins your animations
Here's the part nobody explains properly when they show you a GSAP demo. When you scroll a normal webpage, the browser doesn't give you a smooth continuous stream of scroll position. It gives you scroll position in little discrete jumps. How big those jumps are depends on the device, the input method, the browser, even the operating system. A trackpad on a Mac behaves differently than a mouse wheel on Windows, which behaves differently again on a touchscreen.
Now think about what ScrollTrigger is actually doing under the hood. It's constantly reading your scroll position and mapping it to animation progress. If the scroll position itself is jumpy and inconsistent, then no matter how well you write your animation code, the output is going to inherit that same jumpiness. You could have the most perfectly tuned easing curve in the world and it still won't matter, because the input feeding it is unstable.
This is why pinned sections jump instead of smoothly transitioning. This is why parallax looks incredible on your machine and choppy on a client's laptop. This is why sometimes an animation feels like it's racing ahead of your scroll and other times it feels like it's lagging behind. It's almost never a GSAP problem. It's a scroll problem.
Once I understood this, everything clicked. Every site that had that expensive, smooth feeling wasn't doing anything wildly different in their GSAP code. They were animating against a different kind of scroll entirely, a smoothed, virtual scroll layer instead of raw browser scroll. That's where Lenis comes in.
What Lenis actually does
Lenis is a smooth scroll library, but calling it that undersells what it's really doing. What it actually does is intercept the raw scroll input from your mouse, trackpad, or touch, and instead of applying it directly to the page, it interpolates it into a smooth continuous value over time. Then it applies that smoothed value to the actual scroll position.
The result is that your page now scrolls with this nice eased motion instead of the raw jumpy input. But more importantly for us, it also gives GSAP something stable and predictable to read from. Instead of ScrollTrigger reading messy native scroll events, it reads Lenis's smoothed output. Same animation code, completely different feeling result.
This is the exact setup I use now on basically every client build at my agency that needs scroll driven animation. It's not complicated once you understand why each piece exists, but there are a few steps that people skip or get wrong, and those are exactly the steps that make the difference between something that feels amateur and something that feels premium.
Setting it up
First, install both packages.
npm install lenis gsap
Now here's where a lot of tutorials start you off in a way that actually causes problems down the line. They'll show you initializing Lenis with its own separate animation loop, something like this.
import Lenis from 'lenis'
const lenis = new Lenis({
duration: 1.2,
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
smoothWheel: true,
wheelMultiplier: 1,
touchMultiplier: 2,
})
function raf(time) {
lenis.raf(time)
requestAnimationFrame(raf)
}
requestAnimationFrame(raf)
This will work, technically. Your page will scroll smoothly. But if you're also running GSAP animations tied to scroll, you now have two separate animation loops running independently, Lenis's own requestAnimationFrame loop, and GSAP's internal ticker. They're not talking to each other. They're each doing their own thing on their own timing. And when two things that are supposed to be in sync are actually running independently, you get drift. Small timing mismatches that build up over time, especially noticeable on longer pinned sections where even a tiny desync becomes visually obvious.
I want to point out that easing function for a second too, because it's not just a random formula I pasted in. That exponential curve gives you a quick initial response and then a soft, gradual settle. If you swap it for a simple linear easing, the scroll will still be smooth technically, but it loses that premium feeling. It'll feel more mechanical, more like a slider than an actual physical scroll. Small detail, but it matters more than people think.
The step almost everyone misses
Here's the actual fix for that drift and desync problem, and it's the part I almost never see explained properly in the tutorials that get shared around. Instead of letting Lenis run its own animation frame loop separately from GSAP, you hand control over to GSAP's own internal ticker, and let it drive both Lenis and your animations from the exact same clock.
import gsap from 'gsap'
import ScrollTrigger from 'gsap/ScrollTrigger'
gsap.registerPlugin(ScrollTrigger)
lenis.on('scroll', ScrollTrigger.update)
gsap.ticker.add((time) => {
lenis.raf(time * 1000)
})
gsap.ticker.lagSmoothing(0)
Notice that I removed the separate requestAnimationFrame loop entirely here. You don't need it anymore, because GSAP's ticker is now the single source of truth driving everything. Lenis updates on GSAP's clock, and ScrollTrigger updates whenever Lenis fires a scroll event. Everything is finally reading from the same timeline instead of three separate clocks trying to loosely agree with each other.

That last line, gsap.ticker.lagSmoothing(0), is one of those things that looks like a minor detail but genuinely changes the feel of scroll linked animation. By default, GSAP has this lag smoothing feature that tries to compensate for dropped frames by essentially fast forwarding animation time to catch up. That's a great feature for something like a button hover animation or a modal transition, where the user won't notice or care about a tiny time skip. But on scroll linked animation, where the animation position is directly tied to scroll position, that kind of catch up jump is very visually obvious. It looks like a stutter or a jump. Turning it off means GSAP just processes frames as they come without trying to be clever about catching up, which is exactly what you want here.
Your first scroll animation
Once that foundation is in place, writing the actual animations is pretty normal GSAP work.
gsap.to('.hero-text', {
y: -100,
opacity: 0,
scrollTrigger: {
trigger: '.hero-section',
start: 'top top',
end: 'bottom top',
scrub: 1,
},
})
I want to call out that scrub value specifically, because it's another one of those small details that most people either skip or don't think about. You can set scrub to true, which ties the animation progress directly and instantly to scroll position with zero delay. Or you can set it to a number, like scrub: 1, which adds that number of seconds as a kind of catch up delay between where your scroll actually is and where the animation currently is.
Try both side by side on the same animation and you'll immediately feel the difference. Scrub true feels rigid, like the animation is welded directly to your scrollbar. Scrub with a number like 1 gives you this slight lag that actually reads as smoothness to the eye, like the animation is following your scroll rather than being glued to it. It's a strange thing to explain in words, but once you see it in action you'll understand immediately why almost every polished site uses a numeric scrub value instead of true.
Where things usually break: pinning
If you're doing anything more advanced with pinned sections, meaning sections that stick in place while other content scrolls past or animates within them, this is where I've seen the most Lenis and GSAP setups fall apart, including my own early attempts.
The reason is that pinning actually changes your page's layout. When GSAP pins an element, it's manipulating positioning and effectively changing how tall your page appears to be for scroll purposes. If Lenis calculated its scroll boundaries before that pinning happened, or before some dynamically loaded content changed the page height, its numbers get out of sync with reality. You end up with scroll distances that don't match what's actually on the page, and animations that either end too early, end too late, or just feel broken in some way that's hard to pin down.

The fix is to explicitly refresh ScrollTrigger once you know the page layout is fully settled.
window.addEventListener('load', () => {
ScrollTrigger.refresh()
})
If your page has images or fonts that load asynchronously and affect the layout height after the initial load event, you'll want to trigger a refresh after those finish loading too, otherwise your end values get calculated against a page that hasn't actually finished growing into its final size yet. This one small oversight has caused more of my own scroll bugs than almost anything else.
The little things nobody warns you about
A few smaller issues that will absolutely bite you if you don't know they're coming.
Regular anchor links stop working properly. If you have a navigation link like an anchor tag pointing to a section id, and you click it, the browser will try to jump there using native scroll behavior, which Lenis has no idea about and doesn't intercept by default. You need to redirect those clicks through Lenis's own scroll method instead.
document.querySelectorAll('a[href^="#"]').forEach((anchor) => {
anchor.addEventListener('click', (e) => {
e.preventDefault()
const target = document.querySelector(anchor.getAttribute('href'))
lenis.scrollTo(target)
})
})
The same thing applies to any custom scroll to top button you might have. If you're calling window.scrollTo directly, it'll conflict with what Lenis thinks the scroll position should be. Route it through lenis.scrollTo(0) instead and it'll behave correctly.
Mobile is its own conversation entirely. Lenis's smoothing feels amazing on desktop, where native scroll genuinely does feel a bit rigid and mechanical by comparison. But on mobile, native touch scrolling already feels fluid and responsive on its own, since it's built directly into the OS. Adding Lenis's smoothing on top of that can actually make things feel worse, almost like there's resistance or delay that wasn't there before. What I usually do is just disable the heavier smoothing behavior below a certain screen width.
const lenis = new Lenis({
smoothWheel: window.innerWidth > 768,
syncTouch: false,
})
And if you're coming from an older project that used Locomotive Scroll, which was the go to smooth scroll library a couple years back and is still baked into a lot of older Awwwards style templates floating around, know that Lenis is the more actively maintained and noticeably lighter option today. But the API is quite different, especially around how scroll triggered elements are marked up using data attributes. Don't try to run both libraries side by side hoping one will pick up where the other left off. Pick one and commit to it fully, or you'll end up debugging conflicts between two systems that were never meant to coexist.
Was it actually worth the extra setup
I've used this exact combination on the last handful of agency projects that needed any kind of scroll driven storytelling or reveal animation, and the difference in perceived quality has been honestly bigger than I expected going in. The animations themselves barely changed from what I was already doing. Same easing curves, same triggers, same general approach. What changed was the foundation underneath all of it.
Clients who couldn't tell you what scrub or lag smoothing even means will still say a site feels smoother, feels more expensive, feels more polished, without being able to explain why. That's usually this. Fixing the scroll layer underneath your animations does more for the perceived quality of a site than adding more animations ever will.
If you already have GSAP animations that look great when you test them yourself, but something about them feels slightly off once a real visitor scrolls through the site with their own mouse or trackpad, this is almost always where the problem actually lives. Not in your animation code. In the scroll feeding it.
If you're working through your own Lenis and GSAP setup and something feels off, happy to help troubleshoot in the comments. I build high end animated sites and ecommerce storefronts at my agency, TheBitForge, and this exact stack shows up in almost every project we take on now.

Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.