D
P
0
← All articles Baca dalam Bahasa Indonesia

JavaScript, DOM & Browser Animation

Pinned ScrollTrigger Cards Covering Your CTA? Blame `end: "bottom top"`

· · 7 min read
Pinned ScrollTrigger Cards Covering Your CTA? Blame `end: "bottom top"`

This bug throws nothing into the console. No GSAP warning, no broken layout, no red number in Lighthouse. It only produces one thing, and you catch it with your eyes: the CTA section at the bottom of the page sits underneath a stack of cards that should have released several screens earlier.

I ran into it during a pre-handoff audit of a premium WordPress theme. The homepage had a differentiators section built with the stacking card pattern that is everywhere right now: cards layered on top of each other, each one pinned slightly lower than the last, so scrolling deals them out like a half fanned deck. Six cards, smooth motion, nice transitions. Right up until you scroll a little further.

The symptom: the last card never leaves

Once the card section finished, the CTA was supposed to slide in cleanly from below. Instead card six kept riding along, stuck to the screen, parked on top of the CTA. The button was blocked. Part of the CTA headline peeked out from behind the card. Keep scrolling and the card eventually does release, but far too late: the most important moment on the page was obscured at exactly the point where a visitor should be clicking it.

My first instinct was wrong, and I suspect yours would be the same: I immediately thought z-index. Card on top, CTA below, so raise one or lower the other. I poked at stacking contexts for a while before realising that only hides the symptom. While the card is pinned it should sit above everything. The problem was never who is on top. The problem was that the card was still pinned when it should not have been.

The configuration looked roughly like this:

const cards = gsap.utils.toArray(".stack__card");
 
cards.forEach((card, i) => {
  ScrollTrigger.create({
    trigger: card,
    start: () => `top ${40 + 70 * i}`,
    end: "bottom top", // the culprit
    pin: true,
    pinSpacing: false,
  });
});

What kept me from spotting it: the start line is correct and its result looks great. That 40 + 70 * i offset is what parks each card 70px lower than the one before it, which is exactly the stacking effect the design asked for. Because the clever looking part worked perfectly, I never suspected the line right next to it.

The root cause: bottom top is much longer than it sounds

ScrollTrigger reads start and end with the same grammar every time: the first word is a point on the element, the second word is a point on the viewport. Where those two meet is where the trigger starts or stops.

So end: "bottom top" means the pin is only released when the bottom edge of the card reaches the top edge of the viewport. Play that out slowly. The card is glued to the screen while it is pinned, but the release point is not measured from where it is glued. ScrollTrigger measures it from the card's real position in the document flow, and that position keeps travelling upward as you scroll. As long as the bottom edge of that real position has not passed the ceiling of the viewport, the card counts as active and stays glued.

Compare that to end: "bottom bottom": the pin releases when that same bottom edge merely reaches the bottom of the viewport, right as the element finishes entering the screen. That edge has to cross the entire screen to get from the first point to the second, so the gap between the two values is not a few pixels: it is exactly one viewport height. On a 900px desktop window that is 900 extra pixels where the card is still stuck to the screen.

And those nine hundred pixels are precisely where the CTA section lives.

Why pinSpacing: false turns this into an overlap instead of a gap

This is the part that explains why the bug shows up as overlap rather than as a giant empty hole.

By default, pin: true injects a pin spacer: an empty element as tall as the pin duration, so everything below still gets pushed down and nothing is buried. With that spacer in place, an overly long end gives you awkward whitespace. Ugly, but it covers nothing.

The stacking card effect specifically needs pinSpacing: false, because the cards are meant to overlap each other without inflating the page to several times its real length. The consequence is that no space is reserved at all. Whatever comes next, the CTA in this case, keeps scrolling up as usual and passes underneath the card that is still glued in place.

So two individually reasonable decisions combined into one bug: pinSpacing: false, which the effect genuinely requires, plus an end that outlives its welcome. One removed the safety net, the other kept the pin alive too long.

The fix: measure the end from the wrapper, not from the card

The right fix is not simply swapping bottom top for bottom bottom. If that is all you change, every card releases based on its own bottom edge, and the stack unravels one card at a time from the bottom. What you actually want is the opposite: the whole stack holds until the card section is genuinely done, then lets go together.

That is what endTrigger is for. It separates the element that starts the trigger from the element that ends it, so end is measured against the cards wrapper:

const cardsWrap = document.querySelector(".stack__cards");
const cards = gsap.utils.toArray(".stack__card");
 
cards.forEach((card, i) => {
  ScrollTrigger.create({
    trigger: card,
    start: () => `top ${40 + 70 * i}`,
    endTrigger: cardsWrap,
    end: "bottom bottom",
    pin: true,
    pinSpacing: false,
  });
});

Each card still starts at its own position, but they all stop at the same point: when the wrapper's bottom edge meets the bottom of the viewport. The last card now releases before the CTA arrives instead of after it.

Since the stacking effect only makes sense on wide screens, and on mobile the cards are meant to flow normally one after another, the whole thing is scoped so it never runs on small viewports:

const mm = gsap.matchMedia();
 
mm.add("(min-width: 768px)", () => {
  const cardsWrap = document.querySelector(".stack__cards");
  const cards = gsap.utils.toArray(".stack__card");
 
  cards.forEach((card, i) => {
    ScrollTrigger.create({
      trigger: card,
      start: () => `top ${40 + 70 * i}`,
      endTrigger: cardsWrap,
      end: "bottom bottom",
      pin: true,
      pinSpacing: false,
    });
  });
});

gsap.matchMedia() earns its place here because it tears down every trigger created inside it as soon as the query stops matching. When a visitor rotates a tablet from landscape to portrait, you are not left with an orphaned pin still holding an element hostage.

How I confirmed it was actually fixed

Positioning bugs like this are easy to declare fixed when they have merely shifted, so I did not trust my gut. I took screenshots at several scroll positions around the card to CTA transition and checked one thing that cannot lie: whether the CTA's dark background and white type were fully visible with no sliver of a card peeking over them. Clean CTA in every frame means the pin really does release in the right place.

If you want a faster check without screenshots, markers: true on one of the triggers answers it instantly. An end marker sitting well below the start of the next section is a picture of this exact bug.

What I took away