Sign inGet started

Optimizes GSAP animations for smooth 60fps by using transforms, avoiding layout thrashing, and reducing jank.

Lift+0ppscreen tier · directional
85
Trustsecurity · quality · license · upkeep
Scan Saferisk 0/100
LicenseMITserved via MCP
Skill-matched tasks (generated, public + held-out) · glm-5.3-flash@cf
+0pp95% CI [-38, 38]unverified · screen tier
baseline 75% → skill 75% pass
trigger rate 100% · 24 trials

A/B outputs — median trial per arm

Same task, same model: without the skill (left) vs with it (right).

gsap-performance--m1
baseline · 0.85
Yes, it's true — and it's one of the most common performance tips for GSAP (and CSS animation in general).

## Why transforms are faster

When you animate `left`/`top`, the browser has to run the **full layout pipeline** on every frame:

1. **Layout** — the browser recalculates the position of the element *and potentially every element around/inside it*, since changing `left` can affect document flow.
2. **Paint** — the affected region is re-rendered.
3. **Composite** — layers are assembled.

That's expensive, especially with many elements or complex DOM trees. It's why your animation "feels okay" on a simple page but can stutter on real pages — you're just not seeing the cost yet.

When you animate `transform: translateX(...)`, the browser can usually skip layout and paint entirely and just do the **composite** step on the GPU. The element gets promoted to its own layer and moved like a texture. Much cheaper per frame.

## The GSAP way

GSAP makes this easy because it has built-in transform handling:

```js
// Instead of:
gsap.to(box, { left: 500 });

// Do:
gsap.to(box, { x: 500 });
```

`x` maps to `translateX`, `y` to `translateY`, plus `scale`, `rotation`, etc. GSAP also normalizes cross-browser transform quirks for you.

## Caveats worth knowing

- **`x: 500` is in pixels relative to the element's current position**, not an absolute `left` value. If your element starts at `left: 0`, they're equivalent — but if it starts at `left: 100`, `x: 500` ends at 600px. Keep that in mind when converting existing code.
- **Don't over-promote layers.** Every GPU layer costs memory. Animating one element with transforms is fine; you don't need `force3D` hacks everywhere (GSAP handles `force3D` automatically anyway).
- **Sometimes layout properties are unavoidable** — e.g., animating width in a flex layout. That's fine; just make it a conscious choice, not a default habit.

## Bottom line

Your current animation works, but switching to `x` is a one-line change that scales much better as your page grows in complexity. It's absolutely a habit worth building now — future-you debugging jank on a busy page will thank you.
with skill · 1.00
Yes, it's true — and it's one of the most important GSAP habits to build.

## Why it matters

Animating `left`/`top` changes the element's **layout**. Every frame, the browser must:

1. **Recalculate layout** (where does the box sit, and what does it push around?)
2. **Repaint** the affected area
3. Composite

Animating `x`/`y` (transforms) skips steps 1–2. Transforms are handled almost entirely by the **compositor**, often on the GPU, so the browser just moves an already-rendered layer. That's why it stays smooth at 60fps even when the page is complex — and why `left`/`top` can feel fine on a simple page but jank on a busy one.

## The fix for your case

```javascript
// Before
gsap.to(".box", { left: 500, duration: 1 });

// After
gsap.to(".box", { x: 500, duration: 1 });
```

`x: 500` produces `translateX(500px)` — visually identical for a simple move. If the box's original `left` isn't 0, just animate the delta (`x: 500 - currentLeft`).

## Rules of thumb

- ✅ Animate: `x`, `y`, `scale`, `rotation`, `opacity`
- ❌ Avoid for animation: `left`, `top`, `width`, `height`, `margin`, `padding`
- Add `will-change: transform` in CSS on elements that animate regularly (but only those — don't sprinkle it everywhere)
- If something *must* trigger layout (e.g., an accordion expanding), that's okay occasionally — just don't use layout properties for continuous motion

Your instinct is right: even if it "feels okay" today, transform-based animation is the habit that scales to real-world pages.
gsap-performance--m2
baseline · 0.85
# Fixing Scroll Performance on Mobile

The stutter almost always comes from doing layout-triggering work on every scroll event. Here's how to fix it without a rewrite:

## 1. Don't use scroll events at all — use GSAP ScrollTrigger

If you're already using GSAP, `ScrollTrigger` reads scroll position on its own ticker (rAF-based) and batches updates:

```js
gsap.to(heroImage, {
  yPercent: 30,
  ease: "none",
  scrollTrigger: {
    trigger: heroSection,
    start: "top top",
    end: "bottom top",
    scrub: true
  }
});
```

This alone usually fixes the jank, because scroll events fire irregularly (sometimes multiple times per frame on mobile) while ScrollTrigger syncs to requestAnimationFrame.

## 2. If you must keep the scroll listener: throttle with rAF

```js
let ticking = false;

window.addEventListener("scroll", () => {
  if (!ticking) {
    requestAnimationFrame(update);
    ticking = true;
  }
}, { passive: true });

function update() {
  const progress = window.scrollY / (document.body.scrollHeight - innerHeight);
  progressBar.style.transform = `scaleX(${progress})`;
  ticking = false;
}
```

Key points:
- **`{ passive: true }`** — tells the browser it won't call `preventDefault()`, so scrolling isn't blocked waiting on your handler.
- **One update per frame** — the `ticking` flag collapses multiple scroll events into a single rAF callback.

## 3. Only animate `transform` and `opacity`

- Progress bar: use `transform: scaleX()` instead of changing `width` (width triggers layout every frame).
- Hero image: use `y`/`yPercent` (translate), never `top`/`margin`. GSAP does this by default — just make sure you're not animating CSS properties that cause reflow.
- Add `will-change: transform` to the hero image so it gets its own compositor layer (but only on the few elements that need it — overusing it hurts mobile memory).

## 4. Avoid layout reads inside the handler

Things like `getBoundingClientRect()`, `offsetTop`, or `scrollHeight` inside the handler force synchronous layout. Compute them once (on load/resize) and cache:

```js
let docHeight;
function measure() {
  docHeight = document.body.scrollHeight - window.innerHeight;
}
measure();
window.addEventListener("resize", measure);
```

## 5. Mobile-specific gotchas

- **Debounce resize/orientationchange** — they fire rapidly.
- **Check for expensive CSS on the hero**: large `box-shadow`, `filter: blur()`, or big background images repainting every frame are common culprits. Promote the hero to its own layer (`transform: translateZ(0)`) to isolate repaints.
- **Test with Chrome DevTools → Performance → CPU throttling 4x** to simulate a mid-range phone.

## TL;DR

The 80/20 fix: replace your scroll listener with `ScrollTrigger` + `scrub: true`, make sure everything animates via `transform`, and add `passive: true` if any listener remains. That typically takes mobile from stuttering to locked 60fps.
with skill · 0.91
The stutter almost certainly comes from firing JS on every scroll event and writing styles mid-frame. Here's the minimal fix — no rewrite needed:

## 1. Replace the scroll listener with ScrollTrigger (biggest win)

Scroll events fire irregularly and can run multiple times per frame. ScrollTrigger syncs to GSAP's ticker instead:

```javascript
gsap.registerPlugin(ScrollTrigger);

// Hero image movement
gsap.to("#heroImage", {
  y: -200, // transform only — never top/left
  ease: "none",
  scrollTrigger: {
    trigger: "#heroSection",
    start: "top top",
    end: "bottom top",
    scrub: 1, // smooths and reduces per-frame work
  }
});
```

## 2. Progress bar: use quickSetter or quickTo

If you keep any scroll-driven update, don't create tweens per event:

```javascript
const setProgress = gsap.quickSetter("#progressBar", "scaleX");
// or with ScrollTrigger:
gsap.to("#progressBar", {
  scaleX: 1,
  ease: "none",
  scrollTrigger: { trigger: document.body, start: "top top", end: "max", scrub: 0.3 }
});
```

## 3. Make sure you're only animating transform/opacity

- Hero movement: use `x`/`y`/`scale` — never `top`, `left`, `margin`, or `width/height`.
- Progress bar: animate `scaleX` with `transform-origin: left center` instead of `width`.

## 4. Promote the layers

```css
#heroImage, #progressBar {
  will-change: transform;
}
```

Only on these two elements — not everything.

## 5. Mobile-specific

- Add `force3D: true` in the tween vars if you see paint flicker/stutter on the hero image (keeps it on the GPU).
- If the hero image is large, ensure it's reasonably sized/compressed — a huge image repaint is often the real culprit on mobile.
- If you pin anything, `pin: true` only on the element that needs it.

That's it: ScrollTrigger + scrub replaces your listener, transform-only properties, `will-change` on the two animated elements. This typically takes mobile from stuttering to locked 60fps without restructuring your code.
gsap-performance--m3
baseline · 0.50
## What's actually slowing you down

Two separate problems, and "batching" addresses both:

1. **Animating `left`/`top`** — these are layout properties. Every frame, GSAP writes new values, the browser recalculates layout, then repaints. With 200 cards doing that simultaneously, you're forcing 200 layout recalcs per frame.
2. **200 independent tweens** — each `gsap.to()` creates its own tween object with its own overhead, and each write can trigger its own style recalculation.

## Fix 1: Animate transforms, not layout properties

`left`/`top` trigger layout. `x`/`y` (transforms) don't — they're composited on the GPU and skip layout entirely. This alone usually gives a 10x+ improvement:

```js
// Before (layout thrash)
gsap.to(card, { left: randX, top: randY });

// After (GPU-composited)
gsap.to(card, { x: randX, y: randY, duration: 0.4 });
```

## Fix 2: Batch with `gsap.to()` on a collection

Instead of one tween per card, pass the whole array — GSAP creates a single tween that updates all targets, and internally uses `CSSPlugin`'s batched style writes:

```js
cards.forEach(card => {
  card.addEventListener("mouseenter", () => {
    gsap.to(cards, {
      x: (i, target) => getRandomX(target),
      y: (i, target) => getRandomY(target),
      duration: 0.4,
      ease: "power2.out",
      overwrite: "auto"   // kill conflicting tweens per-target
    });
  });
});
```

The function-based values `(i, target) => ...` let one tween compute a different destination per card.

## Fix 3: Only animate what needs to move

If hovering one card should only move that card (or a few), don't tween all 200 — that's wasted work:

```js
card.addEventListener("mouseenter", () => {
  gsap.to(card, { x: randX, y: randY, duration: 0.4 });
});
```

## Extra wins

- **`overwrite: "auto"`** — prevents tween pileup when hover fires repeatedly.
- **`gsap.set(card, { force3D: true })`** or `will-change: transform` in CSS — promotes cards to their own compositor layers so repaints are cheap. Don't overdo it; 200 layers is fine, but be aware of memory.
- **Debounce/throttle** the random position calculation if you're generating it on every mousemove.
- If you're doing a full shuffle/scatter animation of all 200, consider `gsap.to(cards, {...})` with `stagger: { amount: 0.3, from: "random" }` — one tween, one style pass per frame.

**TL;DR:** The single biggest fix is `left/top` → `x/y`. Batching into one tween over the array helps, but transform-based animation is what actually stops the FPS tank.
with skill · 0.50
## What "batching" means

Two things, and both apply to your case:

1. **One tween for all targets instead of 200 separate tweens.** `gsap.to(".card", {...})` creates a single tween that GSAP updates internally, rather than 200 independent tweens each with their own tick overhead.
2. **Batching reads/writes** — GSAP already batches its DOM writes into one pass per tick, so the real killer in your code isn't batching, it's animating `left`/`top`.

## The actual problem: `left`/`top` cause layout

`left` and `top` trigger **layout + paint** on every frame for 200 elements. That's why FPS tanks. Switch to `x`/`y`, which use `transform: translate()` — compositor-only, no layout:

```javascript
// ❌ layout thrashing
gsap.to(card, { left: randomLeft, top: randomTop });

// ✅ compositor-only
gsap.to(card, { x: randomX, y: randomY, duration: 0.5, ease: "power2.out" });
```

## Batched version

```javascript
const cards = gsap.utils.toArray(".card");

container.addEventListener("mouseenter", () => {
  gsap.to(cards, {
    x: () => gsap.utils.random(-100, 100),  // function-based = unique value per card
    y: () => gsap.utils.random(-100, 100),
    duration: 0.5,
    ease: "power2.out",
    stagger: { each: 0.002, from: "random" } // optional organic feel, still one tween
  });
});
```

One tween, 200 targets, transform-only.

## Extra wins

- **CSS:** `.card { will-change: transform; }` — promotes each card to its own layer.
- **If cards move on hover individually** (frequently updated), use `gsap.quickTo(card, "x")` / `quickTo(card, "y")` — reuses one tween per property instead of spawning new tweens on every event.
- **Kill stale tweens:** if hover fires repeatedly, `gsap.killTweensOf(card)` or `overwrite: "auto"` prevents overlapping tweens piling up.

The single biggest fix is `left/top` → `x/y`; batching the tween is the second.
gsap-performance--m4
baseline · 0.76
## Why you're dropping frames

Three separate problems are compounding:

1. **`getBoundingClientRect()` + `scrollTop` reads inside a ticker** — every read after a write forces a synchronous layout (layout thrash). At 120Hz that's 120 forced reflows/sec.
2. **Three independent timelines/tickers** — each one is a separate rAF callback with its own reads/writes, interleaving read→write→read cycles.
3. **Animating layout-triggering properties** (top/left/width/height, or transform on elements that affect layout) instead of compositor-friendly ones.

## Restructure

### 1. Batch reads and writes — never interleave

```js
// BAD: read → write → read → write per tick
// GOOD: all reads first, then all writes
gsap.ticker.add(() => {
  // ---- READ PHASE ----
  const st = window.scrollY; // use scrollY, not scrollTop on document
  const rect = modalEl.getBoundingClientRect();

  // ---- WRITE PHASE ----
  gsap.set(headerEl, { y: st > threshold ? 0 : -64 });
  gsap.set(progressEl, { strokeDashoffset: computeOffset(st) });
});
```

If reads depend on prior writes, split into two tickers or defer writes with `gsap.ticker.add(fn, false, "post")` ordering, or use `requestAnimationFrame` nesting deliberately.

### 2. Cache what doesn't change per frame

- Cache `rect` on resize/scroll-start, not every tick. If you need scroll-relative position, compute `elementTop = el.getBoundingClientRect().top + scrollY` **once** (and on resize), then derive current position as `elementTop - scrollY` — pure math, zero DOM reads.
- Cache the progress ring's circumference: `const C = 2 * Math.PI * r;` once.

```js
let modalTop; // measured once
function measure() { modalTop = modalEl.getBoundingClientRect().top + window.scrollY; }
window.addEventListener("resize", measure);
measure();

gsap.ticker.add(() => {
  const st = window.scrollY;
  const rel = modalTop - st; // no getBoundingClientRect needed
  // writes only...
});
```

### 3. Animate only `transform` and `opacity`

- Sticky header: don't animate `top`/`height` — use `y` (translateY) and `scale`. Better yet, use real `position: sticky` and let the browser handle it; only GSAP-animate its opacity/scale.
- Modal: animate `y`, `scale`, `autoAlpha` (GSAP's visibility+opacity combo — it flips `visibility` only at the ends, avoiding per-frame style writes).
- Progress ring: `strokeDashoffset` is an attribute/paint change — it's cheap for a small SVG, but if it's large, consider a canvas or a `scaleX` bar instead.

### 4. Consolidate into one timeline

Drive everything from a single timeline with a shared scrubber instead of three competing tickers:

```js
const tl = gsap.timeline({ paused: true });
tl.to(headerEl, { yPercent: -100, ease: "none" }, 0)
  .to(modalEl,   { y: 0, autoAlpha: 1, ease: "power2.out" }, 0.1)
  .to(ringEl,    { strokeDashoffset: 0, ease: "none" }, 0);

// one scroll listener drives the scrub — no ticker reads at all
window.addEventListener("scroll", () => {
  const p = gsap.utils.clamp(0, 1, window.scrollY / maxScroll);
  gsap.to(tl, { progress: p, duration: 0.3, overwrite: true });
}, { passive: true });
```

Scroll-driven scrubbing via `ScrollTrigger` does all of this for you (it batches reads/writes internally and uses `will-change` correctly):

```js
ScrollTrigger.create({
  trigger: section,
  start: "top top",
  end: "bottom bottom",
  scrub: 0.3,
  animation: tl,
});
```

### 5. Misc wins

- Add `{ passive: true }` to scroll listeners.
- Use `gsap.quickSetter(el, "y", "px")` / `quickTo()` for hot-path per-frame writes — skips the full `gsap.set` overhead.
- `force3D: true` on animated elements to keep them on their own compositor layer (but don't over-layer — too many layers hurts too).
- Check the 120Hz issue specifically: GSAP's ticker follows rAF, so it runs at 120fps there — if you're frame-budget-bound, `gsap.ticker.fps(60)` caps work, but the real fix is removing the reads.

**Rule of thumb:** measure once, derive per frame, write in batches, transform-only. That alone typically takes you from thrashing to comfortably under 8ms/frame.
with skill · 0.65
The core problem: reading `scrollTop` and `getBoundingClientRect()` inside a ticker callback forces synchronous layout every frame, and interleaving those reads with GSAP's writes causes layout thrashing. Here's how to restructure:

## 1. Stop reading layout in the ticker

**Scroll position:** use `ScrollTrigger` (it caches scroll internally) or cache `window.scrollY` once per frame via a passive scroll listener — never read it inside the ticker interleaved with writes.

```js
let scrollY = 0;
window.addEventListener("scroll", () => { scrollY = window.scrollY; }, { passive: true });
```

**Element rects:** don't call `getBoundingClientRect()` per frame. Measure once, then re-measure only when layout actually changes:

```js
let headerRect;
function measure() {
  headerRect = header.getBoundingClientRect();
}
measure();
window.addEventListener("resize", gsap.utils.debounce(measure, 150));
// also re-measure after content loads / modal opens
```

If you need positions relative to the document, compute `rect.top + scrollY` once at measure time instead of re-reading.

## 2. Batch reads vs. writes

If you truly must read per frame, do all reads first, then all writes — never alternate:

```js
gsap.ticker.add(() => {
  // READ phase (all of them, back to back)
  const y = window.scrollY;
  const r = ring.getBoundingClientRect();

  // WRITE phase (all of them)
  gsap.set(header, { y: y > 10 ? 0 : -headerRect.height });
  gsap.set(ring, { rotation: y * 0.2 });
});
```

Better yet, replace the ticker entirely with ScrollTrigger-driven tweens so GSAP handles the sync:

```js
gsap.to(header, {
  y: -headerRect.height,
  ease: "none",
  scrollTrigger: { start: 0, end: 100, scrub: 0.5 }
});
```

## 3. Animate only compositor-friendly properties

- Modal: animate `autoAlpha`, `scale`, `y` — not `width`/`height`/`top`/`left`.
- Progress ring: animate `strokeDashoffset` (paint-only, cheap) or use `drawSVG`-style transform; avoid animating SVG attributes that trigger layout.
- Sticky header: use `position: fixed`/`sticky` + `y` transform, and set `will-change: transform` on it, the modal, and the ring only.

## 4. Other wins

- Use `gsap.quickTo()` for anything updated continuously (progress ring value, header offset) instead of creating tweens per frame.
- Pause the modal timeline when the modal is closed (`timeline.pause()`), and kill tweens on teardown.
- On a 120Hz display, GSAP ticks at display refresh — if your ticker callback is heavy, halving the work matters double. Consider `gsap.ticker.fps(60)` to cap the tick rate if the visuals don't need 120Hz.

**Summary:** move all layout reads out of the ticker (cache + re-measure on change), let ScrollTrigger or a read-then-write batch handle sync, and keep every animated property on `transform`/`opacity`. That eliminates the forced reflows and should get you back to a solid 60fps.
gsap-performance--m5
baseline · 1.00
Yes, that's true — blanket `will-change: transform` is usually counterproductive. Here's why and when to actually use it.

## Why "add it everywhere" backfires

`will-change` is a *hint to the browser to prepare for future work*, not a performance booster. When you apply it:

- **It creates a compositor layer for that element.** Each layer consumes GPU memory. Dozens or hundreds of promoted layers (common in long pages with many animated elements) can exhaust GPU memory and cause the browser to evict layers or fall back to slower rendering — the opposite of your goal.
- **Layers aren't free to create.** The browser must rasterize and upload the layer. If you add `will-change` right as an animation starts, you pay that cost at the worst moment.
- **It disables some browser optimizations.** Browsers normally share layers and optimize aggressively; blanket promotion forces them into a worse configuration.
- **It's often redundant with GSAP.** GSAP animates `transform` and `opacity` by default, which are already compositor-friendly. Modern browsers frequently promote elements automatically during transform animations anyway.

## When `will-change` actually helps

1. **Jank at animation *start*.** If you see a hitch on the first frame of an animation (layer creation cost), adding `will-change: transform` *ahead of time* pre-promotes the element so the first frame is cheap.
2. **Persistent, frequently-repeating animations** — a handful of elements that animate constantly (a looping marquee, a spinner, a hero animation that runs on load).
3. **Elements that get repainted despite transform animation** — e.g., a child with a `box-shadow` or background that changes; promotion isolates the repaint.
4. **Fixing flicker/z-fighting artifacts** on 3D-transformed elements.

## Best practice with GSAP

- **Don't set it in CSS permanently.** Add it just before animating and remove it after:

```js
gsap.set(el, { willChange: "transform" });
gsap.to(el, {
  x: 100,
  duration: 1,
  onComplete: () => gsap.set(el, { willChange: "auto" })
});
```

Or use the `force3D` option instead, which is GSAP's own layer-promotion mechanism:

```js
gsap.to(el, { x: 100, force3D: true }); // promotes during animation
```

GSAP sets `force3D: "auto"` by default, which promotes during the animation and demotes afterward — usually exactly the right behavior, making manual `will-change` unnecessary.

- **Limit it to a small number of elements** (single digits per view, roughly).
- **Profile first.** Use DevTools' Rendering → Layer borders and Performance panel. If you're not seeing layer-creation jank or paint storms, you don't need it.

**Rule of thumb:** if GSAP is animating only `transform`/`opacity` and you don't see a first-frame hitch, skip `will-change` entirely.
with skill · 1.00
Yes, your friend is right — blanket `will-change: transform` on everything is an anti-pattern. Here's the breakdown:

## Why "spray it everywhere" backfires

`will-change` tells the browser to promote an element to its own compositor layer. Each layer costs real memory (often width × height × 4 bytes, plus overhead) and adds compositing work every frame. If you put it on dozens or hundreds of elements:

- **Memory bloat** — large or numerous layers can exhaust GPU memory, especially on mobile, causing crashes or forced layer eviction.
- **More compositing cost** — the compositor now has to blend many more layers each frame, which can *hurt* FPS rather than help.
- **Wasted promotion** — elements that aren't animating get layers for nothing.

## When to actually use it

- **Elements that animate continuously or frequently** — mouse followers, looping marquees, scrubbed ScrollTrigger animations, persistent parallax layers.
- **Right before a known animation starts** — the ideal pattern is to add `will-change` just before the animation and remove it after it ends, so the layer only exists while needed:

```js
el.style.willChange = "transform";
gsap.to(el, {
  x: 100,
  onComplete: () => (el.style.willChange = "auto"),
});
```

- **Pinned ScrollTrigger elements** — note that `pin: true` already promotes the pinned element, so you don't need to add it manually there.

## When to skip it

- One-off entrance/exit animations (a fade-in that runs once) — the browser promotes the layer during the animation anyway; a permanent hint just wastes memory.
- Elements animating only `opacity` briefly.
- Anything static.

## The bigger picture

`will-change` is a minor optimization compared to the real wins:

1. **Animate only `transform` and `opacity`** — use GSAP's `x`, `y`, `scale`, `rotation` instead of `width`, `height`, `top`, `left`. This keeps work on the compositor and avoids layout/paint entirely.
2. **Use `stagger`** for lists instead of hundreds of separate tweens.
3. **Use `gsap.quickTo()`** for frequently-updated values like mouse followers.
4. **Kill/pause off-screen animations** and clean up stray tweens and ScrollTriggers.

**Rule of thumb:** if you can't point to a specific element and say "this animates often," it shouldn't have `will-change`. A handful of targeted hints on genuinely hot elements — not a global default — is the correct approach.
Limitations
  • Agent noise: single-model results vary run to run; the confidence interval reflects task-level variance.
  • Screen tier (1 trial/arm) is a directional gate, not a verified claim.
  • Results are for the SkillzBench Reference Agent on glm-5.3-flash@cf and do not necessarily transfer to other agents or models.
  • Task coverage: Skill-matched tasks (generated, public + held-out) only; a skill can shine elsewhere.