How I Build GSAP ScrollTrigger Animations Without Killing Performance
Scroll animation gets a bad reputation because most of it is built badly. Done properly it costs almost nothing; done carelessly it turns a fast site into a stuttering one on exactly the mid-range phones your customers use. The difference is a small number of decisions.
Only two properties are actually cheap
The browser renders in stages: style, layout, paint, composite. Animating a property that changes geometry forces layout, which forces paint, which forces composite — the whole pipeline, every frame.
`transform` and `opacity` skip straight to composite. They can run on the compositor without touching layout at all. That is the entire trick, and almost everything else follows from it.
// Forces layout every frame
gsap.to(el, { top: 200, left: 100, width: 400 });
// Composite only
gsap.to(el, { y: 200, x: 100, scaleX: 1.5 });Animating `height` for an accordion, `top` for a parallax layer, `width` for a progress bar — all of these are layout thrash, and all have a transform equivalent. Use `scaleY` and a transform origin. Use `y`. Use `scaleX` on a full-width bar.
Pin sparingly, and know what it does to the DOM
`pin: true` is the most expensive thing in ScrollTrigger. It wraps your element in a pin-spacer, takes it out of normal flow, and manages its position as you scroll. One or two per page is fine. Six is a page that fights the scroll.
If you only need something to look fixed, `position: sticky` costs nothing by comparison and the browser handles it natively. Reach for pinning when you genuinely need the scroll position to drive a timeline.
Scrub needs a number, not `true`
`scrub: true` binds the timeline to scroll position exactly, so every scroll event maps straight to a timeline seek — including the jitter from a trackpad or a high-resolution wheel.
ScrollTrigger.create({
trigger: section,
start: "top 80%",
end: "bottom 20%",
scrub: 0.6, // catch-up in seconds; smooths the input
animation: tl,
});A scrub value between 0.5 and 1 gives the timeline a short catch-up window. It feels smoother and it collapses a burst of scroll events into one animation frame instead of many.
In React, use a context and clean it up
This is where most React implementations go wrong. Triggers created in an effect without cleanup survive re-renders and route changes, so you accumulate duplicates, each recalculating on scroll. The page gets progressively worse the longer someone browses.
useEffect(() => {
const ctx = gsap.context(() => {
gsap.from(".card", {
y: 40,
opacity: 0,
stagger: 0.1,
scrollTrigger: { trigger: ref.current, start: "top 85%", once: true },
});
}, ref);
// Reverts every tween and trigger created inside the context
return () => ctx.revert();
}, []);`gsap.context` scopes selectors to a root element and gives you one call that undoes everything created inside it. Two lines, and it removes an entire category of bug.
Batch the repeated stuff
Thirty cards with an entrance animation should not be thirty ScrollTriggers. `ScrollTrigger.batch` groups elements entering the viewport together and fires one callback for the group.
ScrollTrigger.batch(".card", {
start: "top 90%",
once: true,
onEnter: (batch) =>
gsap.to(batch, { y: 0, opacity: 1, stagger: 0.08, duration: 0.6 }),
});Respect reduced motion — properly
A meaningful number of people have vestibular disorders, for whom large scroll-driven motion is genuinely unpleasant. `gsap.matchMedia` handles this cleanly and reverts automatically when the preference changes.
const mm = gsap.matchMedia();
mm.add("(prefers-reduced-motion: no-preference)", () => {
// full timelines only for people who want them
});
mm.add("(prefers-reduced-motion: reduce)", () => {
gsap.set(".card", { opacity: 1, y: 0 }); // final state, no motion
});A blanket CSS rule that sets every animation duration to 0.01ms is a reasonable safety net, but it can leave elements stuck at an opacity of 0 if your entrance animation never runs. Setting the end state explicitly is the version that actually works.
Then measure it on a real phone
Everything above is fast on a development machine. Run a Performance trace with 4x CPU throttling and scroll through the page. Long tasks during scroll and a frame rate that dips below 60 are what you are looking for. If you see them, the cause is almost always a property that triggers layout, or triggers that were never cleaned up.
Motion should be the thing people notice least about how a site performs. If the animation is what they remember, and not in a good way, it has failed regardless of how good it looks on your monitor.