← All writing
#GSAP#Animation#Frontend

Scroll-driven storytelling with GSAP - without making people sick

Pinning, scrubbing, and staged reveals can feel cinematic or nauseating. The patterns I use to keep scroll-jacked sections smooth, accessible, and purposeful.

Scroll-driven sections - where the page pins and content animates as you scroll

  • are everywhere in award-style web design. Done well they feel cinematic. Done badly they hijack the scroll, lag a frame behind your finger, and leave people reaching for the back button. Here are the patterns I keep coming back to.

Pin, then drive everything off one progress value

The mistake I see most is animating a dozen things with a dozen separate triggers that drift out of sync. Instead: pin once, and derive every transform from a single progress value between 0 and 1.

ScrollTrigger.create({
  trigger: "#section",
  pin: true,
  scrub: 0.3,
  start: "top top",
  end: "+=300%",
  onUpdate: (self) => apply(self.progress), // one source of truth
});

apply(p) then becomes a pure function of p. Every panel, every clip-path, every fade reads from the same number, so nothing can desync. It’s also trivial to reason about and test.

scrub is what makes it feel attached

Without scrub, a scroll-triggered animation plays on its own timeline and feels disconnected from your input. With it, the animation position is your scroll position. A small scrub value (0.3) adds a touch of smoothing so it glides instead of snapping, without feeling laggy.

Reveal with clip-path, not opacity stacks

For staged reveals - a background, its heading, and an image all uncovering together - animate a single clip-path inset on the container rather than fading each child separately. One wipe, everything inside it revealed by the same logic:

panel.style.clipPath = `inset(${(1 - r) * 100}% 0% 0% 0%)`;

This is how the services section on this site works: a single bottom-up wipe uncovers the colour, the text, and the photo as one coherent motion instead of three things racing each other.

The part everyone skips: reduced motion

Scroll-jacking is exactly the kind of motion that makes some people physically unwell. Honouring prefers-reduced-motion isn’t optional - and the fix is simple. Detect it, skip the pinning, and render a plain stacked layout instead:

if (matchMedia("(prefers-reduced-motion: reduce)").matches) return;

Crucially, the content should live in the DOM regardless of the animation, so the static fallback (and search crawlers) get the full story without the motion.

A short checklist

  • One pin, one progress value - derive all motion from it.
  • scrub for attachment, with light smoothing.
  • will-change and transforms only - never animate layout properties in a scroll loop.
  • Reduced-motion fallback that shows everything statically.
  • Earn the scroll-jack. If pinning doesn’t make the content clearer, a normal scroll is the better design.

The goal isn’t motion for its own sake. It’s pacing - using scroll to reveal a story one beat at a time. When it’s tied tightly to input and respects the people who don’t want it, it feels considered. When it isn’t, it just feels broken.