D
P
0

Web Animation

ScrollTrigger Below the Hero Breaks After Assets Load? GSAP pin vs CSS sticky

June 13, 2026·3 min read
ScrollTrigger Below the Hero Breaks After Assets Load? GSAP pin vs CSS sticky

This is one of the most infuriating animation bugs I've ever chased, because it shows up late — not on load, but after the heavy assets finish downloading. On a cinematic site with a frame-sequence hero (hundreds of frames), every reveal and clip animation in the sections below the hero suddenly misfired: triggers firing at the wrong positions, some not firing at all. At first everything was fine.

After hours, the root cause: GSAP's pin: true injects a pin-spacer at runtime, and that changes the document height after the page has already been laid out.

Why pin: true throws everything off

When you pin the hero with ScrollTrigger (pin: true), GSAP wraps the element in a pin-spacer — an extra div that holds the space while the hero "sticks." This spacer is added at runtime, and its height can change.

The problem shows up with slow-loading assets (frame sequences, large images): once the assets finish and the hero gets its final dimensions, the pin-spacer grows — adding hundreds of pixels to the document height. But every ScrollTrigger below it has already calculated its start/end positions against the pre-pin layout. The result: everything shifts.

And if you use Lenis (smooth scroll), it gets worse: Lenis stores the scroll bounds independently and doesn't automatically adjust when the pin-spacer grows — so smooth scroll and ScrollTrigger fall out of sync.

What's frustrating: even ScrollTrigger.refresh() often isn't enough to recover, especially with Lenis in the mix. I tried invalidateOnRefresh + setTimeout(refresh, 50) — still drifting.

The fix: use CSS position: sticky, not GSAP pin

Instead of letting GSAP manipulate the DOM at runtime, create the scroll space through CSS from the start so the document height never changes:

/* Outer container provides scroll height from the start (t=0) */
.hero-stage {
  height: calc(100svh + 1400px); /* 1400px = scroll distance of the hero effect */
}
 
/* Inner element that "sticks" uses native sticky */
.hero-sticky {
  position: sticky;
  top: 0;
  height: 100svh;
}

Then ScrollTrigger only reads progress, with no pin at all:

gsap.to(heroAnim, {
  // ...animation properties
  scrollTrigger: {
    trigger: ".hero-stage",
    start: "top top",
    end: "bottom bottom",
    scrub: true, // scrub only, NO pin: true
  },
});

Because the document height is fixed from the start (via calc()), no pin-spacer is ever injected, the height never changes as assets load, and every ScrollTrigger below the hero calculates its positions against a stable layout. Lenis doesn't need to re-measure either.

I've used this pattern again and again across React and vanilla projects, and the result is consistent: long hero scroll effects are better off with native sticky than GSAP pin.

Extra notes

  • Use svh (small viewport height), not vh, so it doesn't jump when the mobile address bar appears/disappears.
  • If you need frame 0 drawn immediately before any scroll, force one requestAnimationFrame(draw) on load — don't wait for the first scroll event.
  • If you still want to use GSAP pin (for horizontal scroll, say), make sure every asset that affects height is loaded before you create the ScrollTrigger, then call ScrollTrigger.refresh() once afterward.

The bottom line: the moment a library changes the document height at runtime, every scroll calculation that depends on it becomes fragile. CSS sticky moves that "scroll space" into a declarative, stable layout — and the "animations below the hero misfire after loading" bug disappears.