Sign inGet started

Helps add GSAP animations to Vue, Nuxt, Svelte, and SvelteKit components with correct lifecycle setup, scoped selectors, and cleanup on unmount.

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 [-50, 50]unverified · screen tier
baseline 63% → skill 63% 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-frameworks--m1
baseline · 0.67
Here's a clean pattern using `onMounted` and cleaning up in `onBeforeUnmount`:

```vue
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import gsap from 'gsap'

const cards = ref([
  { id: 1, title: 'Card 1' },
  { id: 2, title: 'Card 2' },
  { id: 3, title: 'Card 3' },
])

let ctx // holds the GSAP context

onMounted(() => {
  // gsap.context scopes selectors to this component AND collects
  // all animations so they can be reverted/cleaned up on unmount
  ctx = gsap.context(() => {
    gsap.from('.card', {
      opacity: 0,
      y: 24,
      duration: 0.6,
      ease: 'power2.out',
      stagger: 0.15, // one by one
    })
  })
})

onBeforeUnmount(() => {
  ctx?.revert() // kills animations and restores inline styles
})
</script>

<template>
  <div v-for="card in cards" :key="card.id" class="card">{{ card.title }}</div>
</template>
```

Key points:

1. **`gsap.context()`** is the recommended way to scope animations in a component. It limits selectors to the component's DOM (avoids accidentally animating `.card` elements elsewhere on the page) and gives you a single handle to clean up.

2. **`ctx.revert()` in `onBeforeUnmount`** kills any in-flight tweens and removes inline styles GSAP added. Without this, a fast unmount mid-animation can leave orphaned tweens running or throw warnings.

3. **`stagger: 0.15`** makes the cards animate one after another. You can also use `stagger: { each: 0.15, from: 'start' }` for more control.

4. If you might re-run the animation later (e.g., when `cards` changes), wrap the tween creation in a function passed to `gsap.context` and call `ctx = gsap.context(fn)` again — or use `gsap.context().add()` to add animations to the existing context so cleanup still covers them.

Optional: if the list can change dynamically, prefer animating with `gsap.from` on newly added items via a `watch`, but for a mount-only fade-in the above is all you need.
with skill · 1.00
Wrap your cards in a root element with a `ref`, create the animation inside `gsap.context()` scoped to that root in `onMounted`, and call `ctx.revert()` in `onUnmounted`:

```vue
<script setup>
import { onMounted, onUnmounted, ref } from "vue";
import { gsap } from "gsap";

const container = ref(null);
let ctx;

onMounted(() => {
  if (!container.value) return;

  ctx = gsap.context(() => {
    gsap.from(".card", {
      autoAlpha: 0,
      y: 24,
      duration: 0.5,
      ease: "power2.out",
      stagger: 0.12, // one by one
    });
  }, container.value); // scope selectors to this component only
});

onUnmounted(() => {
  ctx?.revert(); // kills tweens and restores inline styles
});
</script>

<template>
  <div ref="container">
    <div v-for="(card, i) in cards" :key="card.id" class="card">
      {{ card.title }}
    </div>
  </div>
</template>
```

Why this works:

- **`onMounted`** — the DOM nodes exist by then, so GSAP can safely target them. Creating tweens in `<script setup>` top-level code would run before the elements exist.
- **`gsap.context(..., container.value)`** — the second argument scopes the `.card` selector to this component's subtree, so it won't accidentally grab `.card` elements from other components on the page.
- **`stagger: 0.12`** — offsets each card's start time, producing the one-by-one fade-in. `autoAlpha: 0` fades opacity *and* sets `visibility: hidden` so invisible cards can't be clicked mid-animation.
- **`ctx.revert()` in `onUnmounted`** — kills any still-running tweens (so callbacks don't fire on detached nodes) and removes the inline styles GSAP applied, leaving the DOM clean.

If the cards load asynchronously (e.g. fetched after mount), run the animation after the data arrives — e.g. inside a `watch` on the list with `nextTick`, still inside the same context pattern:

```js
import { watch, nextTick } from "vue";

watch(() => cards.value, async () => {
  await nextTick();
  ctx?.revert(); // clear any previous run
  ctx = gsap.context(() => {
    gsap.from(".card", { autoAlpha: 0, y: 24, stagger: 0.12 });
  }, container.value);
}, { deep: false });
```

Keep `onUnmounted(() => ctx?.revert())` regardless — it's the single cleanup point that guarantees no leaks or detached-node animations.
gsap-frameworks--m2
baseline · 0.85
Here's a solid pattern for SvelteKit + GSAP ScrollTrigger with SSR safety and cleanup:

```svelte
<script>
  import { onMount } from 'svelte';

  let sectionEl;
  let barEl;
  let st; // ScrollTrigger instance

  onMount(() => {
    // Dynamic import keeps GSAP out of SSR entirely
    let ctx;

    (async () => {
      const { default: gsap } = await import('gsap');
      const { ScrollTrigger } = await import('gsap/ScrollTrigger');
      gsap.registerPlugin(ScrollTrigger);

      ctx = gsap.context(() => {
        st = ScrollTrigger.create({
          trigger: sectionEl,
          start: 'top top',
          end: () => `+=${sectionEl.scrollWidth - window.innerWidth}`,
          pin: true,
          scrub: true,
          anticipatePin: 1,
          onUpdate: (self) => {
            // progress goes 0 -> 1 across the pinned scroll
            gsap.set(barEl, { scaleX: self.progress });
          }
        });
      }, sectionEl); // scope selectors/cleanup to this element
    })();

    return () => {
      ctx?.revert(); // kills triggers, tweens, and inline styles
      st = null;
    };
  });
</script>

<section bind:this={sectionEl} class="pin-section">
  <div class="h-scroll">
    <!-- horizontally scrollable content -->
  </div>
  <div class="progress-track">
    <div class="progress-bar" bind:this={barEl}></div>
  </div>
</section>

<style>
  .pin-section { overflow: hidden; }
  .h-scroll { display: flex; overflow-x: auto; }
  .progress-track { height: 4px; background: #ddd; }
  .progress-bar {
    height: 100%;
    background: #333;
    transform-origin: left center;
    transform: scaleX(0);
  }
</style>
```

Key points:

1. **SSR safety**: `onMount` never runs on the server, and the dynamic `import('gsap')` means GSAP code isn't even evaluated during SSR. Don't import GSAP at the top level of the module.

2. **Cleanup**: `gsap.context(..., sectionEl)` scopes everything, and `ctx.revert()` in the returned cleanup kills the ScrollTrigger and restores inline styles — important for client-side navigation in SvelteKit.

3. **Horizontal scroll + pin**: `end` is computed from `scrollWidth - window.innerWidth` so the pin lasts as long as the horizontal content. Using a function for `end` lets it re-evaluate on refresh.

4. **Resize**: add `ScrollTrigger.refresh()` after fonts/images load, or listen for `resize` — ScrollTrigger does this automatically on window resize, but if content loads late (e.g., images), call `st.refresh()` once they're ready.

5. If you're on Svelte 5 with runes, the same pattern works — just use `$state` for the element bindings if you prefer, and keep the cleanup in `onMount`'s return.
with skill · 0.85
Here's a complete, SSR-safe setup. The key points: create everything inside `onMount` (which never runs on the server), scope it with `gsap.context()`, and return a cleanup that calls `ctx.revert()`.

```svelte
<script>
  import { onMount } from "svelte";
  import { gsap } from "gsap";
  import { ScrollTrigger } from "gsap/ScrollTrigger";

  let section;   // pinned section
  let track;     // horizontal scroll container
  let bar;       // progress bar element

  onMount(() => {
    if (!section || !track) return;

    gsap.registerPlugin(ScrollTrigger);

    const ctx = gsap.context(() => {
      const getScrollAmount = () => track.scrollWidth - window.innerWidth;

      // Pin the section and translate the track horizontally
      gsap.to(track, {
        x: () => -getScrollAmount(),
        ease: "none",
        scrollTrigger: {
          trigger: section,
          start: "top top",
          end: () => `+=${getScrollAmount()}`,
          pin: true,
          scrub: 1,
          invalidateOnRefresh: true,
          anticipatePin: 1,
          onUpdate: (self) => {
            // drive the progress bar from scroll progress
            gsap.set(bar, { scaleX: self.progress });
          }
        }
      });
    }, section); // scope: selectors/effects limited to this subtree

    // Re-measure if layout changes (images, fonts, resize)
    const refresh = () => ScrollTrigger.refresh();
    window.addEventListener("load", refresh);

    // Cleanup on destroy: kills tweens + ScrollTriggers, reverts inline styles
    return () => {
      window.removeEventListener("load", refresh);
      ctx.revert();
    };
  });
</script>

<section bind:this={section} class="section">
  <div bind:this={track} class="track">
    <!-- horizontally laid-out panels -->
    <div class="panel">1</div>
    <div class="panel">2</div>
    <div class="panel">3</div>
  </div>
  <div class="progress">
    <div bind:this={bar} class="progress__bar"></div>
  </div>
</section>

<style>
  .section { height: 100vh; overflow: hidden; }
  .track   { display: flex; height: 100%; width: max-content; }
  .panel   { width: 100vw; flex-shrink: 0; }
  .progress { position: fixed; bottom: 0; left: 0; width: 100%; height: 4px; background: #ddd; }
  .progress__bar { width: 100%; height: 100%; background: #333; transform-origin: left; transform: scaleX(0); }
</style>
```

Why this works with SSR and cleanup:

- **SSR-safe:** `onMount` doesn't run during server rendering, so `window`/`ScrollTrigger` are only touched in the browser. No need for `if (browser)` guards.
- **Scoped:** passing `section` as the context scope keeps any selector-based animations limited to this component.
- **Cleanup:** `ctx.revert()` (returned from `onMount`) kills the tween and its ScrollTrigger, unpins the section, and restores inline styles when the component is destroyed.
- **Progress bar:** `onUpdate` gives you `self.progress` (0–1); `scaleX` with `transform-origin: left` animates it cheaply. Alternatively, you could tween the bar with `scrub` instead of `onUpdate` — both are fine.
- **`invalidateOnRefresh: true`** re-evaluates the function-based `end`/`x` values on resize or `ScrollTrigger.refresh()`, so the pin distance stays correct if content width changes.
gsap-frameworks--m3
baseline · 1.00
In Nuxt 3, template refs inside a component are already instance-scoped — each instance gets its own `panel` ref, so no global selectors are needed. Just make sure you never use `document.querySelector('.dropdown')`, which would grab the first match on the page.

```vue
<script setup>
import { ref, watch, onBeforeUnmount } from 'vue'
import gsap from 'gsap'

const isOpen = ref(false)
const panel = ref(null)
let tween = null

watch(isOpen, (open) => {
  // Kill any in-flight tween so rapid toggles don't stack/conflict
  if (tween) tween.kill()

  tween = gsap.to(panel.value, {
    duration: 0.3,
    autoAlpha: open ? 1 : 0,          // opacity + visibility
    y: open ? 0 : -10,                // slide
    ease: open ? 'power2.out' : 'power2.in',
    overwrite: 'auto'                 // safety net against overlapping tweens
  })
})

onBeforeUnmount(() => {
  tween?.kill()
})
</script>

<template>
  <div class="dropdown">
    <button @click="isOpen = !isOpen">Toggle</button>
    <div ref="panel" class="dropdown-panel" :style="{ visibility: 'hidden', opacity: 0 }">
      Panel content
    </div>
  </div>
</template>
```

Key points:

1. **Scoping**: `ref="panel"` + `const panel = ref(null)` binds to that instance's DOM node only. Since each component instance has its own setup scope, multiple copies on the page each animate their own panel.

2. **Avoiding overlapping tweens**:
   - `tween.kill()` before creating a new one, and
   - `overwrite: 'auto'` in the tween config, which automatically kills conflicting tweens on the same properties.

3. **`autoAlpha`** handles both opacity and `visibility`, so the hidden panel isn't clickable/tab-focusable when closed.

4. **Initial state**: set the closed styles inline (or via CSS) rather than relying on a mount-time tween, so there's no flash of the open panel on hydration.

5. **Cleanup**: kill the tween in `onBeforeUnmount` to avoid animating a detached node.

If you prefer CSS-driven enter/leave instead, Vue's `<Transition>` with GSAP in the JS hooks also works, but the watch-based approach above is the simplest for a toggle like this.
with skill · 1.00
Use `gsap.context()` scoped to the component root, and create the tween inside a `watch` on `isOpen`. Use `overwrite: 'auto'` (or a single timeline) to prevent overlapping tweens.

```vue
<template>
  <div ref="root">
    <button @click="isOpen = !isOpen">Toggle</button>
    <div ref="panel" class="dropdown">...</div>
  </div>
</template>

<script setup>
import { ref, watch, onMounted, onUnmounted, nextTick } from "vue";
import { gsap } from "gsap";

const root = ref(null);
const panel = ref(null);
const isOpen = ref(false);
let ctx;

onMounted(() => {
  ctx = gsap.context(() => {
    // initial state: hidden
    gsap.set(panel.value, { autoAlpha: 0, y: -10 });
  }, root.value);
});

watch(isOpen, (open) => {
  nextTick(() => {
    gsap.to(panel.value, {
      autoAlpha: open ? 1 : 0,
      y: open ? 0 : -10,
      duration: 0.35,
      ease: "power2.out",
      overwrite: "auto", // kills conflicting tweens so rapid toggles don't overlap
    });
  });
});

onUnmounted(() => {
  ctx?.revert();
});
</script>
```

Key points:

- **Scoping**: since the component appears multiple times, never animate a global selector like `".dropdown"` — that would hit every instance. Here I animate the `panel` ref directly, which is inherently instance-scoped. If you prefer selector strings, wrap them in `gsap.context(() => { ... }, root.value)` so `.dropdown` only matches inside that instance.
- **No overlapping tweens**: `overwrite: "auto"` kills any active tween on the same properties when a new one starts, so fast toggling reverses cleanly mid-animation. Alternatively, use one paused timeline and `tl.play()` / `tl.reverse()`.
- **Cleanup**: `ctx.revert()` in `onUnmounted` kills tweens and restores inline styles when the component is removed.
- **`autoAlpha`** handles both opacity and `visibility`, so the hidden panel isn't clickable/focusable.

Timeline variant (if you want more control over the sequence):

```js
let tl;
onMounted(() => {
  ctx = gsap.context(() => {
    tl = gsap.timeline({ paused: true })
      .fromTo(panel.value,
        { autoAlpha: 0, y: -10 },
        { autoAlpha: 1, y: 0, duration: 0.35, ease: "power2.out" });
  }, root.value);
});

watch(isOpen, (open) => open ? tl.play() : tl.reverse());
```

The timeline approach is inherently overlap-safe — `play()`/`reverse()` just change direction from wherever the animation currently is.
gsap-frameworks--m4
baseline · 0.82
Here's a clean pattern using Svelte transitions for the exit animation and GSAP for the entrance, plus proper cleanup:

## Parent

```svelte
<script>
  import { flip } from 'svelte/animate';
  import Item from './Item.svelte';

  let items = $state([]);

  function removeItem(id) {
    items = items.filter(i => i.id !== id);
  }
</script>

{#each items as item (item.id)}
  <div animate:flip={{ duration: 300 }}>
    <Item {item} onRemove={() => removeItem(item.id)} />
  </div>
{/each}
```

## Item.svelte — the key part

The trick: **don't** use GSAP for the exit. Svelte's `out:` transition already guarantees the element stays in the DOM until the transition finishes — that's exactly what you're asking for. Use GSAP only for the entrance (via `onMount`), and a Svelte transition for the exit.

```svelte
<script>
  import { onMount, onDestroy } from 'svelte';
  import { gsap } from 'gsap';
  import { fade } from 'svelte/transition';

  let { item, onRemove } = $props();

  let el;
  let entranceTween;

  onMount(() => {
    entranceTween = gsap.from(el, {
      y: 24,
      opacity: 0,
      duration: 0.4,
      ease: 'power2.out'
    });
  });

  // Kill any running GSAP animations when destroyed
  onDestroy(() => {
    gsap.killTweensOf(el);
    entranceTween?.kill();
  });

  // Svelte waits for this to finish before removing the node
  function exit(node, { duration = 300 } = {}) {
    return {
      duration,
      css: t => `opacity: ${t}; transform: translateY(${(1 - t) * 16}px)`
    };
  }
</script>

<div bind:this={el} out:exit>
  <button on:click={onRemove}>Remove</button>
  <span>{item.label}</span>
</div>
```

## Why this structure

1. **Exit-before-removal**: Svelte keyed `each` + `out:` transition holds the node in the DOM until the transition's `duration` elapses. No manual "delay the removal" logic needed.
2. **GSAP entrance on mount**: `onMount` runs after the element is attached, so the tween targets a live node.
3. **Cleanup**: `onDestroy` kills tweens so nothing leaks when the parent unmounts (Svelte destroys children before/with the parent, so this fires for every item).
4. **`animate:flip`**: optional, but makes remaining items smoothly slide into place after one is removed.

## If you must use GSAP for the exit too

Wrap the removal in a promise-based flow instead:

```js
function removeItem(id) {
  const node = document.getElementById(`item-${id}`);
  gsap.to(node, {
    opacity: 0,
    x: -40,
    duration: 0.3,
    onComplete: () => items = items.filter(i => i.id !== id)
  });
}
```

This works but is more fragile (manual DOM lookup, risk of double-clicks re-triggering tweens, no cleanup guarantee). The transition-based approach is the idiomatic Svelte solution — GSAP handles the "in", Svelte handles the "out" and the lifecycle guarantees.
with skill · 0.32
Here's the pattern: the child owns its entrance animation (in `onMount`, scoped with `gsap.context`) and also owns its exit animation — it animates out first, *then* calls `onRemove` to actually remove the item from the parent's array. Cleanup is handled by the context's revert in `onMount`'s return function.

**Item.svelte**

```svelte
<script>
  import { onMount } from "svelte";
  import { gsap } from "gsap";

  export let item;
  export let onRemove;

  let root; // component root element
  let ctx;
  let removing = false;

  onMount(() => {
    ctx = gsap.context(() => {
      // entrance animation
      gsap.from(root, { autoAlpha: 0, y: 20, duration: 0.4, ease: "power2.out" });
    }, root);

    // cleanup when this item is destroyed (or parent destroyed)
    return () => ctx.revert();
  });

  function handleRemove() {
    if (removing) return; // guard against double-clicks
    removing = true;

    // exit animation, then actually remove
    gsap.to(root, {
      autoAlpha: 0,
      x: -40,
      duration: 0.3,
      ease: "power2.in",
      onComplete: () => onRemove(item.id),
    });
  }
</script>

<div bind:this={root} class="item">
  <span>{item.label}</span>
  <button on:click={handleRemove} disabled={removing}>Remove</button>
</div>
```

**Parent.svelte**

```svelte
<script>
  import Item from "./Item.svelte";

  let items = [
    { id: 1, label: "One" },
    { id: 2, label: "Two" },
  ];

  let nextId = 3;

  function addItem() {
    items = [...items, { id: nextId++, label: `Item ${nextId}` }];
  }

  function removeItem(id) {
    items = items.filter((i) => i.id !== id);
  }
</script>

<button on:click={addItem}>Add</button>

{#each items as item (item.id)}
  <Item {item} onRemove={removeItem} />
{/each}
```

Key points:

1. **Exit before removal**: the child animates out with GSAP and only calls `onRemove(item.id)` in `onComplete`, so the keyed `{#each}` doesn't destroy the DOM node until the animation is done. The `removing` flag prevents re-triggering.
2. **Scoped animations**: `gsap.context(callback, root)` with `bind:this={root}` keeps selectors/effects limited to this component instance — important since every item runs its own animation.
3. **Cleanup on destroy**: `onMount`'s returned function calls `ctx.revert()`, which kills the tween and reverts inline styles. This runs both when an individual item is removed and when the whole parent is destroyed, so nothing leaks or animates detached nodes.
4. **Keyed each with `(item.id)`** ensures Svelte tracks items by identity, so adding/removing doesn't re-mount unrelated items (which would replay their entrance animations).

If you'd rather keep removal logic in the parent (e.g. optimistic removal with a delayed state update), an alternative is a parent-level `outro`-style approach, but the child-driven "animate then remove" pattern above is the simplest and most reliable with GSAP.
gsap-frameworks--m5
baseline · 0.15
```vue
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'

const container = ref(null)
let observer = null

onMounted(() => {
  const cards = container.value.querySelectorAll('.card')

  cards.forEach((card) => {
    card.classList.add('card-hidden')
  })

  observer = new IntersectionObserver(
    (entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          entry.target.classList.add('card-visible')
          observer.unobserve(entry.target)
        }
      })
    },
    { threshold: 0.1 }
  )

  cards.forEach((card) => observer.observe(card))
})

onBeforeUnmount(() => {
  if (observer) {
    observer.disconnect()
    observer = null
  }
})
</script>

<style>
.card-hidden {
  opacity: 0;
  transform: translateY(20px);
}
.card-visible {
  opacity: 1;
  transform: translateY(0);
  transition: opacity 0.6s ease, transform 0.6s ease;
}
</style>
```

Key points:

- `container` is a template ref bound via `ref="container"` — with `<script setup>`, a `const container = ref(null)` automatically binds to it.
- `querySelectorAll('.card')` grabs all cards inside the container.
- The `IntersectionObserver` approach means cards animate when they scroll into view (not just on mount). If you want them all to animate immediately on mount regardless of scroll position, you can skip the observer and just add the visible class in a `requestAnimationFrame` after mounting the hidden class:

```js
onMounted(() => {
  const cards = container.value.querySelectorAll('.card')
  cards.forEach((card) => card.classList.add('card-hidden'))
  requestAnimationFrame(() => {
    cards.forEach((card) => card.classList.add('card-visible'))
  })
})
```

Cleanup: yes — if you use the IntersectionObserver, disconnect it in `onBeforeUnmount` so it doesn't keep observing after the component is gone. If you use the simple `requestAnimationFrame` version, there's nothing to clean up (the rAF fires once and completes).
with skill · 0.82
```vue
<script setup>
import { onMounted, onUnmounted, ref } from "vue";
import { gsap } from "gsap";

const container = ref(null);
let ctx;

onMounted(() => {
  if (!container.value) return;
  ctx = gsap.context(() => {
    gsap.from(".card", {
      autoAlpha: 0,
      y: 30,
      duration: 0.6,
      stagger: 0.15,
      ease: "power2.out",
    });
  }, container.value);
});

onUnmounted(() => {
  ctx?.revert();
});
</script>

<template>
  <div ref="container">
    <div class="card">Card 1</div>
    <div class="card">Card 2</div>
    <div class="card">Card 3</div>
  </div>
</template>
```

Key points:

- **Create animations in `onMounted`** — the DOM doesn't exist before that.
- **`gsap.context(callback, container.value)`** scopes the `.card` selector to this component only, so it won't accidentally animate cards elsewhere on the page.
- **Yes, clean up on unmount**: call `ctx.revert()` in `onUnmounted`. This kills the tweens and reverts any inline styles GSAP applied, so nothing keeps running against detached DOM nodes and the component can be safely re-mounted fresh.
- `autoAlpha: 0` fades opacity *and* sets `visibility: hidden`, which is usually better than plain `opacity` for entrance animations.
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.