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.

Everything here is written for GSAP 3.14 and uses only APIs that have been stable across GSAP 3 for several releases.

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:

  1. Layout (also called reflow): the browser calculates the size and position of every element.
  2. Paint: it fills in pixels, colours, text, and shadows.
  3. 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 x and y, not left and top.
  • Resizing something: use scale, not width and height.
  • Hiding something: use opacity and autoAlpha, not display mid-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.

Do not create a tween per event

Layout thrash is one way to overload a frame. Allocation is another, and pointer-driven animation is where it usually shows up.

A mousemove handler can fire dozens of times a second. If each call creates a fresh tween, you are asking GSAP to build, schedule, and garbage-collect hundreds of short-lived objects per second while it is also trying to render.

// BAD: a new tween on every mouse move
element.addEventListener('mousemove', (e) => {
  gsap.to(cursor, { x: e.clientX, y: e.clientY, duration: 0.3 });
});

gsap.quickTo() fixes this. It builds one tween up front and re-targets it on each call, so the handler allocates nothing.

const xTo = gsap.quickTo(cursor, 'x', { duration: 0.3, ease: 'power3' });
const yTo = gsap.quickTo(cursor, 'y', { duration: 0.3, ease: 'power3' });

element.addEventListener('mousemove', (e) => {
  xTo(e.clientX);
  yTo(e.clientY);
});

The same rule applies to anything you drive from a high-frequency callback. Changing a timeline’s speed should be tween.timeScale(value), not gsap.to(tween, { timeScale: value }), which creates a tween to animate a tween.

While you are in that handler, cache your DOM lookups too. A querySelector inside a mousemove searches the document tree on every event for a result that almost never changes.

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-change only 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 and loops 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.

This one catches people out because the browser does some of the work for you and not the rest. A backgrounded tab gets its requestAnimationFrame throttled automatically. An element that has merely scrolled past does not: it keeps burning frames at full rate, invisibly, for as long as the reader stays on the page.

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),
});

The same logic applies to looping tweens and timelines that are not ticker-driven. An infinite marquee or a rotating badge does not need to run below the fold.

ScrollTrigger.create({
  trigger: marquee,
  start: 'top bottom',
  end: 'bottom top',
  onToggle: (self) => (self.isActive ? loop.play() : loop.pause()),
});

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. The same gating is what stops a continuous loop like Infinite Marquee from costing anything while it sits off screen.

Smooth scroll and ScrollTrigger must share one clock

Smooth scroll libraries are everywhere now, and they are one of the most common sources of scroll jank that is not your animation’s fault.

The problem is arithmetic, not effort. A smooth scroller eases the page toward a target position on its own requestAnimationFrame loop. ScrollTrigger updates on GSAP’s ticker, which is a different requestAnimationFrame loop. Two loops, two clocks. The scroller moves the page while ScrollTrigger is still working from the previous frame’s position.

You see this as pins that jitter and scrubbed animations that trail the page by a frame. It is worst on anything both pinned and scrubbed, because there the disagreement compounds.

The fix is to stop the scroller running its own loop and drive it from GSAP’s ticker instead. Here it is with Lenis, though the shape is the same for any smooth scroller that exposes a raf method.

gsap.registerPlugin(ScrollTrigger);

// Smooth scroll is the motion, so it is the part reduced motion should skip.
// ScrollTrigger itself stays registered either way.
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
  const lenis = new Lenis({ autoRaf: false });

  // Push every smooth-scroll frame into ScrollTrigger
  lenis.on('scroll', ScrollTrigger.update);

  // One clock: GSAP's ticker drives the scroller
  const lenisTick = (time) => lenis.raf(time * 1000);
  gsap.ticker.add(lenisTick);

  // Do not clamp the delta after a stalled frame, it desyncs the two again
  gsap.ticker.lagSmoothing(0);

  // A refresh restores native scroll position, so bring the scroller with it
  ScrollTrigger.addEventListener('refresh', () => {
    lenis.scrollTo(window.scrollY, { immediate: true, force: true });
  });
}

Each line fixes a distinct bug, which is why the block only works as a whole:

  • autoRaf: false plus gsap.ticker.add puts both on one loop. This is the part people skip, and skipping it leaves the jitter in place while the code reads as fixed.
  • lenis.on('scroll', ScrollTrigger.update) feeds ScrollTrigger the smooth position rather than letting it poll native scroll.
  • lagSmoothing(0) stops GSAP clamping the time delta after a slow frame or a backgrounded tab. That clamping is useful for ordinary tweens and harmful here, because it puts the two clocks back out of step.
  • the refresh listener resyncs after a resize or a manual ScrollTrigger.refresh(). A refresh recalculates pins and restores the native scroll position while the scroller is still easing toward an older target. Without this the page jumps, then fights the reader.

Two more things worth knowing. Call gsap.ticker.remove(lenisTick) before destroying the scroller, or the ticker keeps calling raf on a dead instance. And smooth scroll libraries generally do not honour prefers-reduced-motion themselves, so gate construction on it as above: an eased page is exactly the kind of motion that preference asks you to drop.

If you would rather not wire this up yourself, GSAP’s own ScrollSmoother is built on ScrollTrigger and shares its clock by construction. It is free on the CDN alongside every other GSAP plugin.

Do not use anticipatePin with a smooth scroller

anticipatePin engages a pin early, by an amount proportional to scroll velocity. It exists for native scroll, where a single wheel event can jump further than ScrollTrigger can react to and you get a frame of unpinned content.

Once a smooth scroller is feeding every frame into ScrollTrigger.update, there is no gap left to cover. The early pin becomes pure error, and the pinned element is yanked upward the moment the section arrives.

// BAD when a smooth scroller drives ScrollTrigger
ScrollTrigger.create({ trigger: stage, pin: stage, scrub: 0.55, anticipatePin: 1 });

// GOOD
ScrollTrigger.create({ trigger: stage, pin: stage, scrub: 0.55 });

This is not a polish detail. Measured on real pinned pages, the element overshot the scroll by around 120px on slow wheel input and up to 330px on fast input, scaling cleanly with velocity. A lurch of a third of the viewport at the exact moment a reader arrives at a section is what “the scroll feels janky when I scroll fast” usually turns out to be.

You can measure it yourself. Sample el.getBoundingClientRect().top and window.scrollY every frame. Before the pin engages, top should fall by exactly the scroll delta, so anything extra is overshoot.

const overshoot = -(scrollY[i] - scrollY[i - 1]) - (top[i] - top[i - 1]);
// 0 on every frame is correct; a positive value means the pin fired early

Two things hide the bug. A pinned section that already fills the viewport gives the eye no stationary reference, and scripted scrolling generates no momentum, so window.scrollTo in a test will show you nothing. Drive it with real wheel input.

Pinned, scrubbed sections are where all of this converges, which is why effects like Horizontal Scroll Section are the ones to check first when a page feels rough.

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.

Fix the causes that only exist on phones. Two ScrollTrigger settings address problems you will never see on a desktop.

// The address bar showing and hiding resizes the viewport, which triggers a
// full refresh and recalculates every trigger mid-scroll.
ScrollTrigger.config({ ignoreMobileResize: true });

// Bigger hammer: moves scrolling onto the JavaScript thread so screen updates
// stay in sync. Fixes pin jitter on iOS, but takes scrolling away from the
// browser's own thread and conflicts with smooth scrollers.
ScrollTrigger.normalizeScroll(true);

ignoreMobileResize is close to a default worth setting. It skips the refresh on touch devices for vertical resizes under a quarter of the viewport height, which is exactly what the address bar produces. normalizeScroll is a real trade-off: reach for it when pinned sections jitter on iOS, and do not combine it with a smooth scroll library that already normalises input.

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();

Your own ticker functions and event listeners are not GSAP’s to clean up, but they can still ride along. gsap.context() accepts a cleanup function returned from its callback, and runs it on revert(). Use named functions so you remove exactly what you added.

function handleMouseMove(e) { /* ... */ }
function tick() { /* ... */ }

const ctx = gsap.context(() => {
  gsap.from('.hero', { y: 50, opacity: 0 });

  gsap.ticker.add(tick);
  element.addEventListener('mousemove', handleMouseMove);

  return () => {
    gsap.ticker.remove(tick);
    element.removeEventListener('mousemove', handleMouseMove);
  };
});

ctx.revert(); // reverts the animations and runs your cleanup function

In React, useGSAP from @gsap/react wraps this pattern in a hook and reverts on unmount for you, including under Strict Mode’s double-invoked effects. There is a fuller treatment in GSAP with React: useGSAP, ScrollTrigger and proper cleanup.

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:

  1. Are you animating transform and opacity rather than left, top, width, or height?
  2. Are you reading and writing layout properties in the same loop? Batch reads before writes.
  3. Do pointer handlers reuse a quickTo instead of creating a tween per event?
  4. Is will-change applied only to elements that are actively animating, then removed?
  5. Are scroll animations driven by ScrollTrigger rather than a raw scroll handler?
  6. Are many similar triggers batched with ScrollTrigger.batch()?
  7. If you use a smooth scroller, is it running on gsap.ticker with lagSmoothing(0) and a refresh resync?
  8. Have you removed anticipatePin from every pinned trigger that a smooth scroller drives?
  9. Do off-screen tickers and loops get paused or removed on scroll?
  10. Have you tested with CPU throttling and, ideally, a real mid-range phone, with ignoreMobileResize set?
  11. Is there a prefers-reduced-motion branch with a visible fallback?
  12. Are ScrollTriggers, tickers, and listeners cleaned up when the view unmounts?

Most jank you will meet in practice is one of the first three items. Fixing which properties you animate, where you read layout, and how much you allocate per event resolves the majority of dropped frames before you reach for anything fancier. If the page uses smooth scroll, check items 7 and 8 next, because those two produce the loudest symptoms for the smallest cause.

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, and avoid allocating a tween per pointer event. Let ScrollTrigger own scroll work instead of raw handlers. Keep your smooth scroller and ScrollTrigger on one clock, and drop anticipatePin when they are. 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, or start with the GSAP scroll animations collection for scroll-linked patterns that already ship with the gating described above.