Sign inGet started

Skill for implementing scroll-based animations, pinning, and scrubbing using GSAP's ScrollTrigger library.

Lift+38ppscreen tier · directional
87
Trustsecurity · quality · license · upkeep
Scan Saferisk 0/100
LicenseMITserved via MCP
Skill-matched tasks (generated, public + held-out) · glm-5.3-flash@cf
+38pp95% CI [13, 75]unverified · screen tier
baseline 63% → skill 100% 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-scrolltrigger--m1
baseline · 0.15
# Fade-In on Scroll with CSS + IntersectionObserver

The cleanest modern approach is a tiny bit of JS (just an observer) plus CSS transitions — no scroll event listeners needed.

## HTML

```html
<section class="fade-in">Section 1</section>
<section class="fade-in">Section 2</section>
<section class="fade-in">Section 3</section>
```

## CSS

```css
.fade-in {
  opacity: 0;
  transform: translateY(20px);
  transition: opacity 0.6s ease-out, transform 0.6s ease-out;
}

.fade-in.visible {
  opacity: 1;
  transform: translateY(0);
}
```

## JavaScript (all of it)

```js
const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        entry.target.classList.add("visible");
        observer.unobserve(entry.target); // animate once, then stop watching
      }
    });
  },
  { threshold: 0.15 } // trigger when 15% of the element is visible
);

document.querySelectorAll(".fade-in").forEach((el) => observer.observe(el));
```

## Why this approach

- **No scroll handlers** — `IntersectionObserver` fires only when elements cross the viewport boundary, so it's much more performant than listening to `scroll`.
- **The animation itself is pure CSS** — the JS only toggles a class.
- **Graceful fallback**: if JS fails, elements stay invisible. To avoid that, you can add the `fade-in` class *via* JS instead of in the HTML, so non-JS users just see everything normally.

## Optional: staggered delay

Add a delay per item for a nicer cascade:

```css
.fade-in:nth-child(2) { transition-delay: 0.15s; }
.fade-in:nth-child(3) { transition-delay: 0.3s; }
```

That's the whole thing — about 20 lines total.
with skill · 1.00
The easiest way is **GSAP + ScrollTrigger** — a few lines of JS handles everything, and it's far more reliable than hand-rolling IntersectionObserver code.

## Setup

```html
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/ScrollTrigger.min.js"></script>
```

## Simple fade-in on scroll

```html
<style>
  .fade-in { opacity: 0; transform: translateY(30px); }
</style>

<script>
  gsap.registerPlugin(ScrollTrigger);

  gsap.utils.toArray(".fade-in").forEach((el) => {
    gsap.to(el, {
      opacity: 1,
      y: 0,
      duration: 1,
      ease: "power2.out",
      scrollTrigger: {
        trigger: el,
        start: "top 80%",   // when element's top hits 80% down the viewport
        once: true          // animate only the first time it enters
      }
    });
  });
</script>
```

Just add `class="fade-in"` to any element you want to reveal. Each one gets its own trigger automatically.

## Bonus: stagger groups of elements

If you have grids of cards, `ScrollTrigger.batch()` animates each group together with a nice stagger:

```js
ScrollTrigger.batch(".card", {
  start: "top 85%",
  once: true,
  onEnter: (batch) =>
    gsap.to(batch, { opacity: 1, y: 0, stagger: 0.1, duration: 0.8 })
});
```

## Tips

- **`once: true`** keeps elements visible after fading in (typical landing-page behavior). Remove it if you want them to fade back out when scrolled past.
- **`start: "top 80%"`** means "start when the element's top reaches 80% of the viewport height" — tweak the percentage to trigger earlier/later.
- Add `markers: true` during development to visualize trigger points, then remove it.
- For accessibility, respect reduced motion: wrap the setup in a check for `window.matchMedia("(prefers-reduced-motion: reduce)")` and skip the animation if matched.

That's it — one small script, and every element with the class fades in as the user scrolls.
gsap-scrolltrigger--m2
baseline · 1.00
Use a **pin + reveal** pattern: the pricing section stays fixed while the following section slides up over it. Here's how with GSAP ScrollTrigger (the most common choice):

```html
<section class="pricing" id="pricing">…</section>
<section class="testimonials">…</section>
```

```css
.testimonials {
  position: relative;
  z-index: 2;
  background: #fff; /* must be opaque so it covers pricing */
}
```

```js
gsap.registerPlugin(ScrollTrigger);

ScrollTrigger.create({
  trigger: "#pricing",
  start: "top top",
  endTrigger: ".testimonials",
  end: "top top",        // pin releases exactly when testimonials reaches the top
  pin: true,
  pinSpacing: false      // key: no extra space added, so testimonials slides over it
});
```

Key points:

- **`pin: true`** freezes the pricing section in place as you scroll.
- **`pinSpacing: false`** is what makes the next section *overlap* it instead of pushing it away — without this, the page just gets taller and pricing scrolls off normally.
- The testimonials section needs an opaque background and a higher `z-index` so it visually covers the pinned section.
- `endTrigger` + `end: "top top"` ties the pin duration to the testimonials section's arrival.

Alternatives:

- **ScrollTrigger + scrub animation**: pin testimonials' wrapper and animate it with `y` from `100vh` to `0` for a smoother "slide-in" feel:
  ```js
  gsap.to(".testimonials", {
    y: 0,
    scrollTrigger: {
      trigger: ".testimonials",
      start: "top bottom",
      end: "top top",
      scrub: true,
      pin: "#pricing",
      pinSpacing: false
    }
  });
  ```
- **CSS-only fallback**: `position: sticky; top: 0` on pricing works if you don't need JS, but sticky elements can't easily be covered by the next section unless you manage stacking contexts carefully.
- **Lenis + GSAP**: pair Lenis smooth scrolling with the same ScrollTrigger setup if you want buttery scroll feel.

One caveat: pinned sections and mobile browsers can be finicky — test on iOS Safari, and consider disabling the pin below a breakpoint with `ScrollTrigger.matchMedia` if it feels cramped on small screens.
with skill · 1.00
Use GSAP ScrollTrigger with `pin: true` and `pinSpacing: false` — that's the classic "pin and let the next section slide over it" pattern.

```html
<section class="pricing">...</section>
<section class="testimonials">...</section>
```

```css
.pricing {
  z-index: 1; /* lower than testimonials so it gets covered */
}
.testimonials {
  z-index: 2;
  position: relative;
  background: #fff; /* opaque so it visually covers the pricing section */
}
```

```javascript
gsap.registerPlugin(ScrollTrigger);

ScrollTrigger.create({
  trigger: ".pricing",
  start: "top top",
  endTrigger: ".testimonials",
  end: "top top",          // pin until testimonials reaches the top
  pin: true,
  pinSpacing: false        // key: no spacer, so testimonials scrolls up over it
});
```

How it works:

- `pin: true` holds the pricing section fixed at the top of the viewport once it hits `start: "top top"`.
- `pinSpacing: false` prevents ScrollTrigger from inserting a spacer element, so the page doesn't reserve empty space — the testimonials section simply continues scrolling up and overlaps the pinned pricing section.
- `endTrigger: ".testimonials"` with `end: "top top"` releases the pin exactly when the testimonials section reaches the top of the viewport.
- The `z-index` layering makes testimonials render on top of the pinned section.

Optional polish: add a subtle parallax or scale-down on the pricing content while it's pinned:

```javascript
gsap.to(".pricing .inner", {
  scale: 0.95,
  opacity: 0.6,
  ease: "none",
  scrollTrigger: {
    trigger: ".testimonials",
    start: "top bottom",
    end: "top top",
    scrub: true
  }
});
```

Tips: use `markers: true` while developing to check trigger positions, and call `ScrollTrigger.refresh()` if the layout changes after load (e.g., images or fonts).
gsap-scrolltrigger--m3
baseline · 0.50
For a vanilla JS parallax hero, the cleanest approach is a **fixed background layer + `transform: translateY()` driven by scroll**, using `requestAnimationFrame` for smoothness. Here's a solid implementation:

## HTML

```html
<section class="hero">
  <div class="hero-bg"></div>
  <div class="hero-content">
    <h1>Your Headline</h1>
    <p>Foreground text moves at normal scroll speed.</p>
  </div>
</section>
```

## CSS

```css
.hero {
  position: relative;
  height: 100vh;
  overflow: hidden;
}

.hero-bg {
  position: absolute;
  inset: 0;
  background-image: url('hero.jpg');
  background-size: cover;
  background-position: center;
  will-change: transform;
  /* make it taller than the section so it can shift without gaps */
  height: 130%;
  top: -15%;
}

.hero-content {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  height: 100%;
}
```

## JavaScript

```js
const bg = document.querySelector('.hero-bg');
const hero = document.querySelector('.hero');
const SPEED = 0.4; // background moves at 40% of scroll speed

let ticking = false;

function updateParallax() {
  const rect = hero.getBoundingClientRect();
  // Only update while the hero is on screen
  if (rect.bottom > 0 && rect.top < window.innerHeight) {
    const offset = window.scrollY * SPEED;
    bg.style.transform = `translate3d(0, ${offset}px, 0)`;
  }
  ticking = false;
}

window.addEventListener('scroll', () => {
  if (!ticking) {
    requestAnimationFrame(updateParallax);
    ticking = true;
  }
}, { passive: true });
```

## Key points

1. **`transform` instead of `background-position`** — transforms are GPU-accelerated and don't trigger layout/paint, so scrolling stays smooth.
2. **`translate3d`** — promotes the element to its own compositing layer.
3. **`requestAnimationFrame` + `ticking` flag** — prevents queuing more work than one frame can handle.
4. **`{ passive: true }`** — tells the browser the handler won't call `preventDefault()`, so scrolling isn't blocked.
5. **Oversized background (130% height)** — gives the layer room to translate without exposing gaps at the edges.

## Optional refinements

- **Respect reduced motion:**

```js
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
  // skip the parallax entirely
}
```

- **Mobile:** iOS Safari handles fixed/transformed backgrounds oddly; consider disabling the effect below ~768px or using `background-attachment: scroll` there.
- If you later want scroll-linked effects site-wide, libraries like **GSAP ScrollTrigger** or **Lenis** handle this well — but for a single hero, the vanilla version above is all you need.
with skill · 0.82
For a hero parallax, I'd recommend **GSAP + ScrollTrigger** — it's free, tiny, and handles scroll-linked animation far more reliably than hand-rolled `scroll` listeners (which are janky and hard to get right across devices).

## Setup

```html
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/ScrollTrigger.min.js"></script>
```

```javascript
gsap.registerPlugin(ScrollTrigger);
```

## The parallax

The trick: animate the background image *down* (or the text *up*) as the hero scrolls out, using `scrub` so it's tied directly to scroll position.

```javascript
// Background moves slower (lags behind scroll)
gsap.to(".hero-bg", {
  yPercent: 25,          // drifts down 25% of its own height
  ease: "none",
  scrollTrigger: {
    trigger: ".hero",
    start: "top top",
    end: "bottom top",
    scrub: true
  }
});

// Foreground text moves faster (or just scrolls naturally)
gsap.to(".hero-text", {
  yPercent: -40,
  opacity: 0,
  ease: "none",
  scrollTrigger: {
    trigger: ".hero",
    start: "top top",
    end: "bottom top",
    scrub: true
  }
});
```

## CSS tips

- Give `.hero` `overflow: hidden` and make `.hero-bg` slightly oversized (`height: 120%` or `scale: 1.2`) so it doesn't reveal gaps as it shifts.
- Use `yPercent`/transforms (not `top`/`margin`) — transforms are GPU-accelerated and won't trigger layout.

## Why not vanilla JS?

You *can* do it with a `scroll` event + `requestAnimationFrame` + `transform: translateY(scrollY * 0.5)`, but you'd have to handle throttling, mobile scroll quirks, resize recalculation, and smoothness yourself. ScrollTrigger's `scrub` gives you all of that in a few lines, and `scrub: 0.5` (a number instead of `true`) adds a nice smoothing lag if you want a softer feel.

If you want to stay dependency-free for now, the minimal vanilla version is:

```javascript
const bg = document.querySelector(".hero-bg");
let ticking = false;
window.addEventListener("scroll", () => {
  if (!ticking) {
    requestAnimationFrame(() => {
      bg.style.transform = `translateY(${window.scrollY * 0.4}px)`;
      ticking = false;
    });
    ticking = true;
  }
}, { passive: true });
```

But for anything beyond a single hero, GSAP is the better long-term choice.
gsap-scrolltrigger--m4
baseline · 0.50
Here's a complete, self-contained implementation using vanilla JS (no libraries):

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Horizontal Scroll Gallery</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }

  body {
    font-family: system-ui, sans-serif;
    background: #111;
    color: #fff;
  }

  /* Intro section before the gallery */
  .intro {
    height: 100vh;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 2rem;
  }

  /* The pinned section */
  .gallery-section {
    position: relative;
    /* Height = viewport + horizontal distance to travel */
    height: 400vh; /* adjust: taller = slower scroll */
  }

  .sticky-viewport {
    position: sticky;
    top: 0;
    height: 100vh;
    overflow: hidden;
  }

  /* Progress bar */
  .progress-track {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 4px;
    background: rgba(255, 255, 255, 0.15);
    z-index: 10;
  }

  .progress-bar {
    height: 100%;
    width: 100%;
    background: #4ade80;
    transform-origin: left center;
    transform: scaleX(0);
    will-change: transform;
  }

  /* The moving track of panels */
  .gallery-track {
    display: flex;
    height: 100%;
    will-change: transform;
  }

  .panel {
    flex: 0 0 100vw;
    height: 100%;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 3rem;
  }

  .panel:nth-child(1) { background: #0f172a; }
  .panel:nth-child(2) { background: #1e293b; }
  .panel:nth-child(3) { background: #334155; }
  .panel:nth-child(4) { background: #475569; }

  .outro {
    height: 100vh;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 2rem;
  }
</style>
</head>
<body>

  <section class="intro">Scroll down ↓</section>

  <section class="gallery-section" id="gallerySection">
    <div class="sticky-viewport">
      <div class="progress-track">
        <div class="progress-bar" id="progressBar"></div>
      </div>
      <div class="gallery-track" id="galleryTrack">
        <div class="panel">Panel 1</div>
        <div class="panel">Panel 2</div>
        <div class="panel">Panel 3</div>
        <div class="panel">Panel 4</div>
      </div>
    </div>
  </section>

  <section class="outro">Keep scrolling ↓</section>

<script>
  const section = document.getElementById('gallerySection');
  const track = document.getElementById('galleryTrack');
  const progressBar = document.getElementById('progressBar');

  function updateGallery() {
    const rect = section.getBoundingClientRect();
    const totalScroll = section.offsetHeight - window.innerHeight; // scrollable distance
    const scrolled = Math.min(Math.max(-rect.top, 0), totalScroll);
    const progress = totalScroll > 0 ? scrolled / totalScroll : 0;

    // How far the track must move: its width minus one viewport
    const maxX = track.scrollWidth - window.innerWidth;
    track.style.transform = `translate3d(${-progress * maxX}px, 0, 0)`;

    // Progress bar
    progressBar.style.transform = `scaleX(${progress})`;
  }

  // rAF-throttled scroll listener for smoothness
  let ticking = false;
  window.addEventListener('scroll', () => {
    if (!ticking) {
      requestAnimationFrame(() => {
        updateGallery();
        ticking = false;
      });
      ticking = true;
    }
  }, { passive: true });

  window.addEventListener('resize', updateGallery);
  updateGallery();
</script>
</body>
</html>
```

**How it works:**

- **Pinning:** The `.gallery-section` is tall (`400vh`), and the inner `.sticky-viewport` uses `position: sticky; top: 0` so it stays fixed while the section scrolls past.
- **Horizontal movement:** Scroll progress through the section (0→1) is mapped to a `translate3d` on the flex track, moving it left by up to `trackWidth − viewportWidth`.
- **Progress bar:** The same progress value drives `scaleX()` on the bar — cheap and GPU-friendly (no width recalculation).
- **Tuning:** Increase `.gallery-section` height (e.g., `600vh`) for slower scrolling, or change panel widths (`flex-basis`) for different panel sizes. The math adapts automatically via `scrollWidth`.

If you're using GSAP in your project, the ScrollTrigger plugin can replace the JS with ~10 lines (`pin: true, scrub: true, x: () => -(track.scrollWidth - innerWidth)`), but the vanilla version above has zero dependencies.
with skill · 0.85
Here's a complete, self-contained example using GSAP ScrollTrigger:

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Horizontal Scroll Gallery</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }

  body { font-family: sans-serif; background: #111; color: #fff; }

  /* Intro section so there's something to scroll past */
  .intro {
    height: 100vh;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 2rem;
  }

  /* Pinned gallery section */
  .gallery-section {
    position: relative;
    overflow: hidden;
    height: 100vh;
  }

  .gallery-track {
    display: flex;
    height: 100vh;
    width: max-content; /* grows to fit all panels */
    align-items: center;
    will-change: transform;
  }

  .panel {
    width: 80vw;
    height: 70vh;
    margin: 0 2vw;
    border-radius: 12px;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 3rem;
    flex-shrink: 0;
  }

  .panel:nth-child(1) { background: #e63946; }
  .panel:nth-child(2) { background: #457b9d; }
  .panel:nth-child(3) { background: #1d3557; }
  .panel:nth-child(4) { background: #2a9d8f; }
  .panel:nth-child(5) { background: #e9c46a; color: #222; }

  /* Progress bar */
  .progress-bar {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 4px;
    background: rgba(255, 255, 255, 0.15);
    z-index: 10;
  }

  .progress-fill {
    height: 100%;
    width: 0%;
    background: #e63946;
    transform-origin: left center;
  }

  .outro {
    height: 100vh;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 2rem;
  }
</style>
</head>
<body>

  <section class="intro">Scroll down ↓</section>

  <section class="gallery-section">
    <div class="progress-bar"><div class="progress-fill"></div></div>
    <div class="gallery-track">
      <div class="panel">1</div>
      <div class="panel">2</div>
      <div class="panel">3</div>
      <div class="panel">4</div>
      <div class="panel">5</div>
    </div>
  </section>

  <section class="outro">The end ✨</section>

  <script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/ScrollTrigger.min.js"></script>
  <script>
    gsap.registerPlugin(ScrollTrigger);

    const track = document.querySelector(".gallery-track");
    const section = document.querySelector(".gallery-section");

    // Distance to move = track width minus one viewport
    const getScrollAmount = () =>
      track.scrollWidth - window.innerWidth;

    gsap.to(track, {
      x: () => -getScrollAmount(),
      ease: "none",
      scrollTrigger: {
        trigger: section,
        start: "top top",
        end: () => `+=${getScrollAmount()}`,
        pin: true,
        scrub: 1,           // smooth catch-up; use true for direct 1:1
        invalidateOnRefresh: true, // recalc on resize
        anticipatePin: 1,
        onUpdate: (self) => {
          // Progress bar fills with scroll progress
          gsap.set(".progress-fill", {
            width: `${self.progress * 100}%`
          });
        }
      }
    });
  </script>
</body>
</html>
```

**How it works:**

- **Pinning:** `pin: true` on the `.gallery-section` holds it fixed in the viewport while the user scrolls through the `end` range (`+=scrollAmount`).
- **Horizontal movement:** The tween translates the track left by `track.scrollWidth - window.innerWidth` — the amount of horizontal overflow — with `ease: "none"` so movement maps linearly to scroll.
- **Progress bar:** `onUpdate` gives you `self.progress` (0–1), which drives the fill width. Since it's inside the pinned section, it stays visible during the whole gallery scroll.
- **`invalidateOnRefresh` + function-based values** keep everything correct on resize/orientation change.

Tips: adjust `scrub: 1` (higher = smoother/laggier), and if you want the bar to span the full page instead of just the gallery, move it outside the section and use `position: fixed`.
gsap-scrolltrigger--m5
baseline · 0.79
Here's a complete, smooth scroll progress bar:

```html
<div id="progress-bar"></div>
<main>
  <p>...</p>
  <!-- lots of content -->
</main>
```

```css
#progress-bar {
  position: fixed;
  top: 0;
  left: 0;
  height: 4px;
  width: 0%;
  background: linear-gradient(90deg, #6366f1, #ec4899);
  z-index: 9999;
  /* smooths out small scroll steps */
  transition: width 0.1s linear;
  pointer-events: none;
}
```

```js
const bar = document.getElementById('progress-bar');

function updateProgress() {
  const scrollTop = window.scrollY;
  const docHeight = document.documentElement.scrollHeight - window.innerHeight;
  const progress = docHeight > 0 ? scrollTop / docHeight : 0;
  bar.style.width = (progress * 100) + '%';
}

// rAF throttling: only run once per frame, so it's smooth and cheap
let ticking = false;
window.addEventListener('scroll', () => {
  if (!ticking) {
    requestAnimationFrame(() => {
      updateProgress();
      ticking = false;
    });
    ticking = true;
  }
});

updateProgress(); // set correct value on load (e.g., page opened mid-scroll)
```

Key points:

- **Width = scroll percentage**: `(scrollY / (scrollHeight - innerHeight)) * 100`. The guard `docHeight > 0` avoids division by zero on short pages.
- **Smoothness**: the `requestAnimationFrame` throttle ensures the calculation runs at most once per frame, and the small CSS `transition: width 0.1s linear` eases between frames so it never looks jumpy. If you want it perfectly raw (no easing), drop the transition.
- **`pointer-events: none`** so the thin bar never blocks clicks on content underneath.
- **`z-index: 9999`** keeps it above sticky headers; adjust if you have a fixed navbar (you can also offset it with `top: <navbar-height>`).
- The initial `updateProgress()` call handles reloads where the browser restores scroll position.

Optional upgrade: for even smoother rendering, animate `transform: scaleX()` instead of `width` — it avoids layout recalculations:

```css
#progress-bar {
  transform-origin: 0 50%;
  transform: scaleX(0);
}
```
```js
bar.style.transform = `scaleX(${progress})`;
```

Both approaches work; `scaleX` is slightly more performant on very long pages.
with skill · 1.00
Here's a clean GSAP ScrollTrigger approach — it's the smoothest way to do this, since `scrub` gives you a nice eased catch-up instead of raw scroll-position jumps.

## HTML

```html
<div id="progress-bar"></div>
<main>
  <p>...</p>
</main>
```

## CSS

```css
#progress-bar {
  position: fixed;
  top: 0;
  left: 0;
  height: 4px;
  width: 100%;
  transform: scaleX(0);          /* animate transform, not width */
  transform-origin: left center; /* fills from the left */
  background: linear-gradient(90deg, #6366f1, #ec4899);
  z-index: 9999;
  pointer-events: none;
}
```

## JS

```html
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/ScrollTrigger.min.js"></script>
<script>
  gsap.registerPlugin(ScrollTrigger);

  gsap.to("#progress-bar", {
    scaleX: 1,
    ease: "none",
    scrollTrigger: {
      trigger: "main",
      start: "top top",          // when main's top hits viewport top
      end: "bottom bottom",      // when main's bottom hits viewport bottom
      scrub: 0.5                 // 0.5s catch-up = smooth, not jumpy
    }
  });
</script>
```

## Key points

- **`scaleX` instead of `width`** — transform animations are GPU-accelerated and don't trigger layout/reflow on every scroll tick, so it stays buttery even on long pages.
- **`scrub: 0.5`** — the playhead takes half a second to "catch up" to the scroll position, which smooths out jumpy wheel/touchpad scrolling. Use `scrub: true` for a 1:1 direct link, or a larger number (e.g. `1`) for a lazier feel.
- **`start: "top top"` / `end: "bottom bottom"`** — the bar goes 0% → 100% across the full scrollable range of the article. If you want it to hit 100% exactly when the article's end scrolls into view (rather than when it reaches the viewport bottom), use `end: "bottom center"` or `end: "bottom 80%"`.
- **`ease: "none"`** — important; you want linear mapping between scroll and progress, not an eased tween.

If you ever need to react to the progress value (e.g. show "42% read"), add an `onUpdate` callback:

```js
scrollTrigger: {
  trigger: "main",
  start: "top top",
  end: "bottom bottom",
  scrub: 0.5,
  onUpdate: (self) => console.log(Math.round(self.progress * 100) + "%")
}
```

No library alternative: a plain `scroll` listener setting `style.width` works too, but it fires irregularly and animating `width` causes layout thrashing — the GSAP version above is noticeably smoother for exactly this use case.
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.