Skip to content

Web Development

GSAP ScrollTrigger Pinning Without Breaking Scroll.

Pin the stage. Animate inside it. Keep the document honest.

Aidxn
Written by Aiden Wood Founder and Lead Design Engineer
Native scroll to staged story A normal document enters a pinned stage, moves through labelled content, then returns to normal document flow. SOURCEdata in DECIDElogic RESULTaction out

Native scroll to staged story

A normal document enters a pinned stage, moves through labelled content, then returns to normal document flow.

Preview Process Sticky

A pinned section is not premium because it moves. It is premium when it explains one idea without stealing the visitor's scroll, focus, or patience.

ScrollTrigger is good at this because it ties an animation to native scroll. The common failure is treating the generated pin spacer as a hand built layout primitive. Do not. Set a deliberate trigger range, let ScrollTrigger supply pin spacing by default, and animate children inside the pinned element.

What pinning actually does

During the active scroll range, ScrollTrigger keeps a chosen element in place. By default it adds spacing after the pin so following content catches up correctly once the pin ends. That default is why a simple implementation does not overlap the section after it.

pinSpacer is a rare configuration escape hatch, useful when you need to supply a spacer such as an iframe wrapper. It is not a div you should add to every template. Use pinSpacing: false only when the surrounding layout deliberately owns the space.

A React pattern with real cleanup

import { useLayoutEffect, useRef } from 'react';
import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';

gsap.registerPlugin(ScrollTrigger);

export function ProcessStage() {
  const sectionRef = useRef<HTMLElement>(null);
  const stageRef = useRef<HTMLDivElement>(null);

  useLayoutEffect(() => {
    const media = gsap.matchMedia();

    media.add('(prefers-reduced-motion: no-preference)', () => {
      const scope = sectionRef.current;
      if (!scope || !stageRef.current) return;

      const q = gsap.utils.selector(scope);
      const timeline = gsap.timeline({
        defaults: { ease: 'none' },
        scrollTrigger: {
          trigger: scope,
          pin: stageRef.current,
          start: 'top top',
          end: () => '+=' + window.innerHeight * 2,
          scrub: 0.6,
          anticipatePin: 1,
          invalidateOnRefresh: true,
          snap: {
            snapTo: 'labelsDirectional',
            duration: { min: 0.15, max: 0.45 },
          },
        },
      });

      timeline
        .addLabel('first')
        .fromTo(q('[data-step=first]'), { autoAlpha: 0, y: 24 }, { autoAlpha: 1, y: 0 })
        .addLabel('second')
        .to(q('[data-step=first]'), { autoAlpha: 0, y: -24 })
        .fromTo(q('[data-step=second]'), { autoAlpha: 0, y: 24 }, { autoAlpha: 1, y: 0 }, '<');

      return () => {
        timeline.scrollTrigger?.kill();
        timeline.kill();
      };
    });

    return () => media.revert();
  }, []);

  return (
    <section ref={sectionRef}>
      <div ref={stageRef}>
        <div data-step='first'>First decision</div>
        <div data-step='second'>Second decision</div>
      </div>
    </section>
  );
}

scrub: 0.6 means the animation takes about 0.6 seconds to catch up with scroll position. It does not mean 60 percent smooth. Labels make snapping understandable and maintainable. matchMedia gives reduced motion visitors the same content in normal document order.

Horizontal galleries need one distance calculation

Vertical input can reveal a horizontal gallery without intercepting the wheel. The key is to pin the stage, calculate the track overflow, and animate that exact distance. Recalculate during refresh because responsive card widths change.

const distance = () => track.scrollWidth - window.innerWidth;

const tween = gsap.to(track, {
  x: () => -Math.max(0, distance()),
  ease: 'none',
  scrollTrigger: {
    trigger: section,
    pin: true,
    start: 'top top',
    end: () => '+=' + Math.max(1, distance()),
    scrub: true,
    invalidateOnRefresh: true,
  },
});

If elements inside the horizontal track need their own ScrollTriggers, use containerAnimation with this linear tween. Do not add pins or snaps to those nested triggers. The outer stage already owns that job.

Use CSS sticky when no animation earns the dependency

A sticky media column with a scrolling text list is often the better answer. It is smaller, works without JavaScript, and gives screen readers a conventional reading order. Reach for ScrollTrigger when scroll progress genuinely teaches the idea, not because a competitor has a pinned section.

Velocity Components fit

Process Sticky is the subscription ready implementation of this decision. It keeps the illustration pinned while steps move through ordinary page order. It is a pattern, not a visual hostage situation: motion can stop and the message still lands.

Accessibility checklist

  • Keep all essential copy in linear DOM order.
  • Never require scroll progress to reveal a button or form field.
  • Test keyboard focus while the stage is pinned.
  • Use reduced motion to remove the animation, not the content.
  • Test a short viewport and a large text zoom level before shipping.

Sources and further reading

Let us make some quick suggestions?

Please provide your full name.
Please provide your phone number.
Please provide a valid phone number.
Please provide your email address.
Please provide a valid email address.
Please provide your brand name or website.
Please provide your brand name or website.