
gsap-scrolltrigger
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
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.