An animation that looks perfect on your laptop can stutter badly on a mid-range phone. The tween is correct, the easing is right, and yet the motion drops frames and feels cheap.
Jank is rarely the fault of GSAP itself. GSAP is one of the most heavily optimised animation engines available. The stutter almost always comes from what you ask the browser to do on each frame: the properties you animate, the layout work you trigger, and how much runs while the page scrolls.
This guide explains why animations drop frames and gives you a practical checklist for keeping GSAP smooth. The techniques apply whether you write your own tweens or drop in a ready-made effect.
What “janky” actually means
The browser tries to paint a new frame roughly every 16.7 milliseconds, which is 60 frames per second. On a 120Hz display that budget drops to about 8 milliseconds.
If the work for a frame does not finish inside that budget, the browser misses the deadline. That missed frame is jank: motion that should be continuous instead jumps, hesitates, or tears.
So performance work is really one question: how do you do less work per frame? Everything below is a variation on that theme.
The compositor versus the main thread
To understand why some properties are cheap and others are expensive, you need a rough mental model of how the browser draws a page. It happens in stages:
- Layout (also called reflow): the browser calculates the size and position of every element.
- Paint: it fills in pixels, colours, text, and shadows.
- Composite: it stacks the painted layers together into the final image.
Layout and paint run on the main thread, the same thread that runs your JavaScript. Compositing can run on a separate thread, often with help from the GPU.
Here is the key insight. If you animate a property that only affects the composite stage, the browser can update the frame without touching layout or paint. That work is cheap and it can happen off the main thread, so it stays smooth even when your JavaScript is busy.
Two properties give you this for free: transform and opacity.
Animate transforms and opacity, not layout properties
This is the single most important rule for smooth motion.
// Cheap: only affects compositing
gsap.to('.box', {
x: 300, // transform: translateX
scale: 1.2, // transform: scale
rotation: 45, // transform: rotate
opacity: 0.5,
duration: 0.6,
});
Every property above maps to transform or opacity. The browser can animate them without recalculating layout, so it stays on the compositor.
Now compare the version that fights the browser:
// Expensive: forces layout on almost every frame
gsap.to('.box', {
left: 300, // triggers layout
width: 400, // triggers layout
marginTop: 40, // triggers layout
duration: 0.6,
});
Animating left, top, width, height, margin, or padding forces the browser to recalculate layout on nearly every frame of the tween. On a complex page that recalculation can blow past your frame budget on its own.
The practical translations are worth memorising:
- Moving something: use
xandy, notleftandtop. - Resizing something: use
scale, notwidthandheight. - Hiding something: use
opacityandautoAlpha, notdisplaymid-tween.
GSAP’s autoAlpha is a small quality-of-life win here. It combines opacity with visibility, so an element fades to zero and then flips to visibility: hidden, removing it from interaction without a layout-triggering display: none.
Understand layout thrash
Layout thrash is a specific, common cause of jank, and it is worth naming because it is easy to trip over.
Reading a layout property (like offsetWidth, getBoundingClientRect, or scrollTop) forces the browser to flush any pending layout so it can give you an accurate value. Writing a layout property invalidates the layout again. If you interleave reads and writes in a loop, you force the browser to recalculate layout repeatedly within a single frame.
// BAD: read, write, read, write forces layout on every iteration
items.forEach((el) => {
const height = el.offsetHeight; // read: forces layout
el.style.height = height * 2 + 'px'; // write: invalidates layout
});
The fix is to batch: read everything first, then write everything.
// GOOD: all reads, then all writes
const heights = items.map((el) => el.offsetHeight); // all reads
items.forEach((el, i) => {
el.style.height = heights[i] * 2 + 'px'; // all writes
});
GSAP helps you avoid this in animation code because it batches its own DOM writes through a single internal ticker. The trap usually appears in your own event handlers and setup code, especially when you measure elements to feed values into a tween. Measure once, cache the result, and reuse it.
will-change and GPU layers, used sparingly
You can hint to the browser that a property is about to animate, so it promotes the element to its own compositor layer ahead of time:
.card {
will-change: transform;
}
Promoting an element to its own layer means the browser can move it around without repainting its neighbours. That is genuinely useful for the element you are about to animate.
The mistake is applying it to everything. Each layer consumes memory, and on mobile GPUs that memory is limited. Put will-change: transform on hundreds of elements and you can make performance worse, not better, sometimes crashing the compositor entirely.
Two rules keep you safe:
- Apply
will-changeonly to elements that are actually about to animate, and ideally remove it once the animation is done. - Do not treat it as a magic “make fast” switch. It is a hint about the near future, not a permanent setting.
For animations you trigger on interaction, add the hint just before and clear it after:
const card = document.querySelector('.card');
card.addEventListener('mouseenter', () => {
card.style.willChange = 'transform';
gsap.to(card, {
scale: 1.05,
duration: 0.3,
onComplete: () => { card.style.willChange = 'auto'; },
});
});
force3D and layer promotion in GSAP
GSAP has its own mechanism for pushing an element onto the GPU: the force3D setting. When active, GSAP applies a 3D transform (like translate3d or matrix3d) even for 2D motion, which nudges the browser to promote the element to its own layer.
By default GSAP uses force3D: "auto", which promotes elements while they animate and then reverts them afterward, so you get the benefit during motion without leaving orphaned layers behind. For most work you never need to touch this.
You can force it on for a specific tween when you have measured a benefit:
gsap.to('.hero-image', {
x: 200,
duration: 1,
force3D: true, // keep it on the GPU for this tween
});
Be deliberate. Forcing 3D on many elements recreates the same over-promotion problem as overusing will-change. Reach for it when profiling shows a specific element repainting during a transform-only animation, not as a default.
ScrollTrigger performance
Scroll is where performance problems get loud, because scroll events fire constantly and any work you attach to them runs at that same rapid pace.
Never animate inside a scroll handler
If you find yourself writing window.addEventListener('scroll', ...) and setting styles inside it, stop. That pattern runs your code on every scroll event, often faster than the browser can paint, and it bypasses GSAP’s frame batching entirely.
// BAD: work runs on every scroll event, unthrottled
window.addEventListener('scroll', () => {
const progress = window.scrollY / maxScroll;
element.style.transform = `translateY(${progress * 100}px)`;
});
ScrollTrigger exists precisely to replace this. It listens to scroll once, batches the work into GSAP’s ticker, and gives you a clean declarative API.
// GOOD: ScrollTrigger batches the work for you
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
gsap.registerPlugin(ScrollTrigger);
gsap.to('.element', {
y: 100,
ease: 'none',
scrollTrigger: {
trigger: '.element',
start: 'top bottom',
end: 'bottom top',
scrub: true,
},
});
}
Use scrub for scroll-linked motion
When an animation should follow scroll position, use scrub rather than trying to recalculate positions yourself. A numeric value like scrub: 1 adds a short smoothing catch-up, which both looks better and spreads the work across frames instead of forcing it all into one.
scrollTrigger: {
trigger: '.panel',
start: 'top top',
end: '+=1000',
scrub: 1, // 1 second of smoothing between scroll and animation
}
Batch many triggers into one
A common pattern is revealing dozens of cards as they enter the viewport. Creating a separate ScrollTrigger for every card works, but it means many trigger instances all doing bounds calculations. ScrollTrigger.batch() groups them and processes elements together as they cross the threshold.
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
gsap.registerPlugin(ScrollTrigger);
ScrollTrigger.batch('.card', {
start: 'top 85%',
onEnter: (batch) =>
gsap.from(batch, { y: 40, opacity: 0, stagger: 0.1, overwrite: true }),
});
}
This is the pattern behind grid reveals like the Stagger Grid Reveal effect, where many items share one efficient trigger rather than one each.
Run tickers only when visible
For continuous animations driven by gsap.ticker, such as particle fields or canvas loops, there is no reason to keep computing frames while the element is scrolled off screen. Add and remove the ticker function based on visibility.
function tick() {
// per-frame animation logic
}
ScrollTrigger.create({
trigger: canvas,
start: 'top bottom',
end: 'bottom top',
onEnter: () => gsap.ticker.add(tick),
onEnterBack: () => gsap.ticker.add(tick),
onLeave: () => gsap.ticker.remove(tick),
onLeaveBack: () => gsap.ticker.remove(tick),
});
Canvas-heavy effects like Canvas Particle Flow and the frame playback in Scroll Image Sequence benefit most from this, since their per-frame cost is high and pausing it off screen is free performance.
Test on real devices, not just your laptop
Your development machine is faster than the phone most of your visitors carry. An animation that never drops a frame on a desktop can struggle on a three-year-old Android.
Two habits catch most problems before users do:
Throttle the CPU. Chrome DevTools has a Performance panel with a CPU throttling dropdown. Set it to “4x slowdown” or “6x slowdown” and rerun your animation. Frame drops that were invisible at full speed become obvious.
Record a performance trace. Hit record in the Performance panel, run the animation, and stop. Long tasks show up as red-flagged blocks, and purple “Layout” or green “Paint” bars during a supposedly transform-only animation tell you something is triggering work it should not.
If you can, test on an actual mid-range phone over your local network. Nothing substitutes for the real thing, and it reliably surfaces issues that emulation smooths over.
prefers-reduced-motion is an accessibility and performance win
Some users set an operating system preference to reduce motion, whether for vestibular disorders, migraines, or simple preference. Respecting prefers-reduced-motion is not optional, and it is the clearest example of a change that helps accessibility and performance at the same time. The animation you skip is work the device never has to do.
GSAP’s matchMedia makes this clean. Set up full animation for users who are fine with motion, and a static fallback for those who are not.
const mm = gsap.matchMedia();
mm.add('(prefers-reduced-motion: no-preference)', () => {
gsap.from('.reveal', {
y: 40,
opacity: 0,
stagger: 0.1,
scrollTrigger: { trigger: '.reveal', start: 'top 85%' },
});
});
mm.add('(prefers-reduced-motion: reduce)', () => {
// No motion: just make sure content is visible
gsap.set('.reveal', { opacity: 1, y: 0 });
});
The reduced-motion branch matters. If your only animation is a fade-in from opacity: 0, skipping it without a fallback leaves the content invisible. Always set the final visible state explicitly.
Cleanup and memory
The last source of jank is subtler and shows up over time rather than immediately: leaked animations and triggers. This matters most in single-page apps, where components mount and unmount without a full page reload. Every ScrollTrigger you create but never kill keeps listening. Every ticker function you add but never remove keeps running. They accumulate, and eventually the page feels heavy for no obvious reason.
gsap.context() is the tool for this. Wrap your setup in a context, and a single revert() call cleans up every tween, timeline, and ScrollTrigger created inside it.
const ctx = gsap.context(() => {
gsap.from('.hero', { y: 50, opacity: 0 });
gsap.to('.parallax', {
y: -100,
scrollTrigger: { trigger: '.parallax', scrub: true },
});
});
// When the component unmounts or the view changes:
ctx.revert();
If you are adding your own ticker functions or event listeners, clean those up too. Use named functions so you can remove exactly what you added.
function handleMouseMove(e) { /* ... */ }
function tick() { /* ... */ }
gsap.ticker.add(tick);
element.addEventListener('mousemove', handleMouseMove);
function cleanup() {
gsap.ticker.remove(tick);
element.removeEventListener('mousemove', handleMouseMove);
ScrollTrigger.getAll().forEach((t) => t.kill());
}
Long, continuously running animations deserve special care. For infinite marquees and loops, avoid patterns that create a new tween on every cycle, such as calling gsap.to() inside an onRepeat. A single reusable gsap.quickTo() with a wrap modifier loops seamlessly without allocating fresh tweens, which keeps memory flat over long sessions.
The performance checklist
When an animation feels janky, work through these in order:
- Are you animating
transformandopacityrather thanleft,top,width, orheight? - Are you reading and writing layout properties in the same loop? Batch reads before writes.
- Is
will-changeapplied only to elements that are actively animating, then removed? - Are scroll animations driven by ScrollTrigger rather than a raw scroll handler?
- Are many similar triggers batched with
ScrollTrigger.batch()? - Do off-screen tickers get removed and re-added on scroll?
- Have you tested with CPU throttling and, ideally, a real mid-range phone?
- Is there a
prefers-reduced-motionbranch with a visible fallback? - Are ScrollTriggers, tickers, and listeners cleaned up when the view unmounts?
Most jank you will meet in practice is one of the first two items. Fixing which properties you animate, and where you read layout, resolves the majority of dropped frames before you reach for anything fancier.
Conclusion
Smooth animation is not about a secret setting. It is about respecting the browser’s frame budget and doing less work per frame.
Keep motion on transform and opacity so it stays on the compositor. Avoid forcing layout in loops. Let ScrollTrigger own scroll work instead of raw handlers. Promote elements to GPU layers deliberately, not everywhere. Test on hardware slower than your own, honour reduced-motion preferences, and clean up what you create.
Do those things and GSAP will hold 60fps on far weaker devices than you might expect. The engine was never the bottleneck; the work you hand it was.
Looking for production-ready GSAP effects with performance and cleanup already handled? Browse the GSAP Vault effects library for scroll animations, canvas effects, and interactive components you can drop into any project.
Related articles
- Animate on Scroll: 3 Approaches Compared: compare CSS scroll animations, Intersection Observer, and GSAP ScrollTrigger, and see where each one fits.
- GSAP vs CSS Animations: A Practical Guide: when CSS is enough, when GSAP earns its bundle size, and the performance trade-offs of each.
- GSAP vs Anime.js vs Motion Compared: bundle sizes, features, and a decision framework across three popular animation libraries.
- Apple-Style Scroll Image Sequences: a canvas and ScrollTrigger technique where per-frame cost makes the performance patterns above essential.