Scroll-driven animation looks great in a portfolio site, and it's also one of the easiest places to accidentally tank your Core Web Vitals if you build it wrong. Here's the actual pattern I use to get smooth scroll effects without the jank — and the specific mistakes that cause it.
The one rule that matters more than any other
Only animate transform and opacity. Never animate width, height, top, left, margin, or box-shadow directly in a scroll-driven animation. Those properties trigger layout recalculation on every scroll tick, and at 60fps that recalculation is what causes visible stutter on anything but a high-end device.
// Bad — triggers layout on every scroll frame
gsap.to(".card", {
scrollTrigger: { trigger: ".card", scrub: true },
width: "300px",
top: "50px"
});
// Good — GPU-accelerated, no layout thrashing
gsap.to(".card", {
scrollTrigger: { trigger: ".card", scrub: true },
scale: 1.2,
y: 50
});
Basic scrub animation
scrub: true ties the animation's progress directly to scroll position instead of playing on a timer — this is what makes it feel "attached" to the scrollbar rather than just triggered once.
gsap.registerPlugin(ScrollTrigger);
gsap.to(".hero-image", {
scrollTrigger: {
trigger: ".hero-section",
start: "top top",
end: "bottom top",
scrub: 1 // smoothing — higher = more lag/smoothness
},
scale: 1.3,
opacity: 0.4
});
The scrub: 1 (instead of scrub: true) adds a small smoothing delay between scroll position and animation progress — it's the difference between animation that feels mechanically tied to the scrollbar versus one that feels like it has actual weight to it.
Pinning a section
Pinning locks an element in place while the user keeps scrolling — the classic "text changes while the image stays fixed" effect:
ScrollTrigger.create({
trigger: ".pin-section",
start: "top top",
end: "+=800", // pin for 800px of scroll
pin: true,
pinSpacing: true // keeps document flow correct after unpin
});
The most common bug here is forgetting pinSpacing, which leaves a layout gap or overlap once the pin releases — easy to miss in development since it often only shows up after content below the pinned section is added.
Batching triggers for content-visibility gains
If you have many similar elements (a grid of cards animating in on scroll), don't create one ScrollTrigger per element — use ScrollTrigger.batch() instead. It groups elements entering the viewport together instead of running individual calculations for each one:
ScrollTrigger.batch(".fade-in-card", {
onEnter: batch => gsap.to(batch, {
opacity: 1,
y: 0,
stagger: 0.15,
overwrite: true
}),
start: "top 85%"
});
Killing triggers on route change or resize
If this is part of an SPA or any dynamically re-rendered layout, stale ScrollTriggers pointing at removed DOM nodes are a real memory leak and a real source of "why is this animation firing on the wrong element" bugs:
function cleanupScrollTriggers() {
ScrollTrigger.getAll().forEach(trigger => trigger.kill());
}
Call this before re-initializing animations on route/content change, then re-run your setup function afterward.
The performance check that actually matters
Chrome DevTools → Performance tab → record a scroll pass. Look at the Rendering section specifically for Layout Shift and Paint events firing during scroll — if you see repeated layout/paint blocks stacked up during the scrub, something in your animation is touching a non-GPU-accelerated property, even if it's not obvious from the code which one.
I build this kind of animated, performance-conscious front-end work for client sites — more at capareach.com.
Top comments (0)