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.
scrubfor attachment, with light smoothing.will-changeand 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.