Scroll animation performance comes down to two questions I ask about every effect before it ships: does it keep the browser on the cheap path while someone scrolls, and does it get out of the way for the people who asked their device for less motion? Get both right and scroll motion feels like polish. Get either wrong and it feels like a slow, nauseating website.
This is the working method I use with GSAP and ScrollTrigger on Next.js sites, including this one. It assumes you already know what GSAP is. If you are still deciding whether a site needs heavy interactive work at all, start with when an interactive 3D website is worth it, because most of what follows is about the lighter, cheaper option that post recommends trying first.
Why scroll animations get slow
Every frame the browser draws can pass through five stages: JavaScript, style, layout, paint and composite. Google's rendering performance guide puts the practical budget at about 10 milliseconds of your own work per frame on a 60 Hz display, because the browser needs the rest of the roughly 16.66 milliseconds for its own housekeeping. Scroll is the hardest place to stay inside that budget, because it fires constantly and the user is watching the screen the whole time.
The fix is not mysterious. The same source explains that when only compositor properties change, the browser can skip layout and paint entirely. In web.dev's animations guide the rule is stated plainly: restrict animations to opacity and transform, and avoid any property that triggers layout or paint unless it is absolutely necessary.
In practice, the slow scroll effects I see on audits share a short list of causes:
- Animating
top,left,width,height,marginorpadding, which forces layout on every frame. - Animating
filter: blur(), largebox-shadowvalues orbackground-positionon big areas, which forces repaint. - Reading layout inside a scroll handler (
getBoundingClientRect(),offsetTop) and then writing styles, which makes the browser recalculate layout mid-frame. - Dozens of separate triggers each doing a little work, instead of a few doing the right work.
- Smooth-scroll libraries layered on top of an already busy page, so the scroll itself becomes JavaScript work.
What GSAP and ScrollTrigger already do for you
GSAP's own ScrollTrigger documentation says scroll events are debounced and updates are synchronized with GSAP and screen refreshes, and that ScrollTrigger does its processing up front to work out start and end points, then watches only the scroll position rather than constantly measuring elements. Those positions are calculated when a trigger is created and recalculated when the scroller is resized, with resize recalculation waiting for a 200 millisecond gap in resize events. That design is why I reach for it instead of writing scroll listeners by hand: it removes the most common mistake, layout reads on every scroll event, before I start.
What it cannot do is make an expensive property cheap. If you tell GSAP to tween height on scroll, it will faithfully force layout 60 times a second. The library is fast; the property you choose decides the cost.
Six rules I follow for fast scroll animation
1. Move things with transforms, fade with opacity
In GSAP that means x, y, xPercent, yPercent, scale, rotation and opacity. The site background on jnaikcreates.solutions is three soft colour washes that drift as you scroll. Each one tweens only xPercent, yPercent and scale, plus a background colour on three small fixed elements. A version that animated gradient stops across the full viewport would look similar in a screenshot and cost far more per frame.
This also protects layout stability. The web.dev CLS guide recommends moving elements with transform: translate() instead of changing top, right, bottom or left, and sets a good CLS score at 0.1 or less. I explain what that metric means for a business owner in Core Web Vitals explained.
2. Play reveals once, scrub only what should be scrubbed
Most entrance animations should run one time and then stop costing anything. ScrollTrigger's once: true option, per the docs, makes the trigger kill itself as soon as the end position is reached once. My reveal component uses it on every fade-up block. A reveal that replays every time you scroll back up is extra work for the browser and, honestly, a little irritating to read.
Scrub, which links animation progress directly to the scrollbar, is for effects where the scroll position is the point: a background that drifts down the page, a product rotating as you read about it. A number instead of true adds smoothing; the docs describe scrub: 0.5 as half a second of catch-up. My background uses scrub: 1.2 so it lags gently behind the finger and never feels jittery on a trackpad.
3. Batch work instead of creating hundreds of triggers
One trigger per card in a 40-card grid is 40 sets of start and end positions to calculate on every refresh. I put a gs-stagger class on the grid's parent and create one trigger for the container, which then cascades its children in with a stagger. Same visual result, a fraction of the bookkeeping.
4. Pin carefully
Pinning is where scroll effects most often stutter. The ScrollTrigger docs advise animating the pinned element's children rather than the pinned element itself, and offer anticipatePin to apply the pin slightly early so there is no flash of unpinned content during fast scrolls. I add invalidateOnRefresh: true to any pinned or scrubbed animation whose values depend on viewport size, so cached start values are thrown away after a resize. The same docs warn against using content-visibility: auto on trigger elements, because it breaks position calculations.
5. Clean up, especially in React
In a Next.js app, components mount, unmount and, in development Strict Mode, run effects twice. GSAP's React guide explains that the useGSAP hook handles cleanup through gsap.context() and reverts animations automatically when the component unmounts. Without that, you get duplicate triggers stacking up on every client-side navigation, and the page gets slower the longer someone browses. My reveal component is mounted once in the root layout and re-runs on route change, which only works because each run reverts the last.
6. Use will-change as a fix, not a habit
The web.dev animations guide says to use will-change sparingly and only when you hit an actual performance issue. The same guide notes that browsers usually make good layer decisions on their own, and that forcing layers manually can introduce performance problems of its own. Putting it on every animated element is exactly that kind of forcing. I add it only after a trace shows a problem it solves.
Accessible by default: reduced motion done properly
Motion is not harmless decoration for everyone. Two WCAG success criteria apply directly. 2.2.2 Pause, Stop, Hide (Level A) requires a way to pause, stop or hide moving content that starts automatically, lasts more than five seconds and runs alongside other content, unless the motion is essential. 2.3.3 Animation from Interactions (Level AAA) asks that motion triggered by interaction can be disabled unless it is essential, and W3C's explanation of the criterion uses parallax scrolling as an example of non-essential motion. W3C lists the CSS and JavaScript use of prefers-reduced-motion as techniques for meeting it.
AAA is the strictest WCAG conformance level, but for scroll-driven sites I treat 2.3.3 as the standard anyway. It costs almost nothing with GSAP, and the people it protects are real visitors, not edge cases.
My pattern has three parts:
- Wrap all scroll motion in
gsap.matchMedia(). I register everything under(prefers-reduced-motion: no-preference). GSAP's matchMedia docs confirm that when a query stops matching, every animation and ScrollTrigger created inside it is reverted automatically, so if someone switches the setting on mid-visit, the motion stops without a reload. - Set the hidden state in JavaScript, never in CSS. If your stylesheet says
.reveal { opacity: 0 }and the script fails, or the reduced-motion branch never runs, the content stays invisible. I setopacity: 0andy: 26withgsap.set()inside the motion branch. No JavaScript, or reduced motion on, means everything is simply visible. - Never let focus land on something invisible. A keyboard user can Tab into a block before its fade has run. My reveal script listens for
focusinand snaps any still-hidden block to full opacity immediately.
Here is the shape of it, trimmed from the component that runs on this site:
useGSAP(() => {
const mm = gsap.matchMedia();
mm.add("(prefers-reduced-motion: no-preference)", () => {
gsap.utils.toArray(".gs-reveal").forEach((el) => {
gsap.set(el, { opacity: 0, y: 26 });
gsap.to(el, {
opacity: 1, y: 0, duration: 0.8, ease: "power3.out",
scrollTrigger: { trigger: el, start: "top 88%", once: true },
});
});
});
});
Reduced motion does not have to mean zero motion. A short opacity fade with no movement is usually fine. What I remove is travel, parallax, zoom and anything that scrubs large areas with the scroll. On this site, the background washes simply stay where they start.
How to test scroll animation performance
Do not judge on a fast laptop. Chrome DevTools lets you simulate a slower device: in the Performance panel, open capture settings and choose a 4x CPU slowdown, record while you scroll, then look for red bars above the FPS chart, which mark where the framerate dropped. Long purple layout blocks or green paint blocks during the scroll tell you which rule above you broke.
Then test the accessibility side. In the DevTools Rendering tab, emulate prefers-reduced-motion: reduce and scroll the whole page. Every piece of content should be visible and readable. Finally, try it on a real device with the setting turned on: MDN's prefers-reduced-motion reference lists where it lives, for example Settings, Accessibility, Motion on iOS and Settings, Accessibility, Remove animations on Android.
One note on metrics. Interaction to Next Paint does not measure scrolling: web.dev's INP guide says hovering, zooming and scrolling are not observed, only clicks, taps and key presses, with 200 milliseconds or less counted as good. But heavy scroll-linked JavaScript still occupies the main thread, so a tap on a button mid-scroll can wait behind it. Good scroll hygiene protects INP indirectly.
A pre-launch checklist
| Check | Pass looks like |
|---|---|
| Animated properties | Only transform and opacity on scroll; anything else has a reason |
| Entrance reveals | once: true, no replay on scroll-up |
| Grids and lists | One trigger per container with stagger |
| Pinned sections | Children animated, not the pin; invalidateOnRefresh where values depend on viewport |
| React cleanup | All animation inside useGSAP; no duplicate triggers after navigation |
| Reduced motion | All motion inside a matchMedia no-preference branch; content visible with setting on |
| No-JS fallback | Hidden states set in JavaScript only |
| Keyboard focus | Tabbing into an unrevealed block shows it immediately |
| Autoplaying motion over five seconds | Has a pause or stop control |
| Slow-device trace | No sustained red frame bars at 4x CPU slowdown |
When not to animate on scroll
Scroll motion is a seasoning. I leave it out of forms, pricing tables, legal pages, long reading content and anything a stressed person needs to get through quickly. My rule is that forms and estimators stay still, and the motion lives on the story sections around them. I am also wary of scroll-jacking (taking over the native scroll speed or snapping between sections). GSAP's normalizeScroll(), which the docs say forces scrolling onto the JavaScript thread, solves specific mobile problems, but it is a tool for a known bug, not a default.
And if a page has nothing worth revealing, revealing it will not help. One strong moment, such as a hero that settles in or a single scrubbed product shot, beats fading in every paragraph.
Frequently asked questions
Is GSAP bad for page speed?
Not by itself. The cost comes from what you animate and how many triggers you create. A few transform and opacity tweens with once: true add very little work per frame. Load GSAP only on pages that use it if your framework allows code splitting.
Should I use CSS scroll-driven animations instead?
For simple effects, CSS is worth trying first because there is no library to load. I still use GSAP when I need timelines, staggered sequences or pinning, and because matchMedia makes the reduced-motion branch so easy to keep honest.
Does respecting reduced motion mean turning off all animation?
No. WCAG 2.3.3 is about motion animation. Swapping a slide-and-scale entrance for a plain fade, or for no transition, is usually the right move. What matters is that nothing large travels, zooms or scrubs with the scroll for people who asked it not to.
Can scroll animations hurt SEO?
Indirectly, yes, if they hide content without JavaScript, shift layout or slow the page. Set hidden states in JavaScript, animate transforms instead of layout, and the content stays in the HTML where search engines can read it.
Want scroll motion that earns its place?
I build GSAP motion into Next.js sites as part of the system, not as an afterthought, as I did on the PrintVision site and on Nuraflow. If your current site feels heavy when you scroll, or you want motion that works for every visitor, take a look at my services or send me the link and tell me what feels off. I will tell you which effects to keep, which to fix and which to cut.




