Debugging the Unclickable Button: A Deep Dive into GSAP, Stacking Contexts, and Pointer Events

Today
7 min read
2,248 views
225 likes
Category
Beginner
#GSAP#ScrollTrigger#React#CSS#Debugging#Web Development

The Mystery of the Unclickable Button

It started with a simple user report: the "Read More" button on our project cards wasn't working. It was part of a beautiful, Apple-inspired hero animation where project cards would appear in an overlay as the user scrolled, before revealing the main projects section. The button was visible, the cursor changed to a pointer, but clicks did nothing. No navigation, no console errors—just silence. This began a deep dive into the intricate world of browser rendering, revealing that the most obvious solutions are rarely the right ones when complex animations are involved.

Chapter 1: The Obvious Suspects (pointer-events)

Anyone familiar with CSS layering would immediately suspect the pointer-events property. If an invisible element sits on top of a button, it will intercept the clicks. The initial hypothesis was that one of the overlay containers was the culprit.

The structure was roughly:

<div id="hero-section" style="pinnning...">
  <div id="blackout-overlay"> <!-- z-index: 50 -->
    <div id="cards-column"> <!-- z-index: 5 -->
      <!-- Each card wrapper is absolutely positioned -->
      <div data-overlay-project="0">
        <div class="card-shell">
          <a href="..." class="read-more-button">Read More</a>
        </div>
      </div>
      <div data-overlay-project="1">...</div>
    </div>
  </div>
</div>

My first attempts were straightforward: ensure all parent containers of the button allow pointer events. I systematically changed pointer-events: none to pointer-events: auto on the blackoutOverlay and cardsColumn.

// Initial Fix Attempt
blackoutOverlay.style.pointerEvents = "auto";
cardsColumn.style.pointerEvents = "auto";

Result: Failure. The user reported, "It's not that clicking has no effect, but it feels like I didn't click it at all." This was a crucial clue. The event wasn't just being ignored; it was being swallowed by something else entirely.

Chapter 2: The Plot Thickens (Stacking Contexts and z-index)

The next suspect was the stacking context. The entire hero animation was pinned using GSAP's ScrollTrigger. When an element is pinned, ScrollTrigger often wraps it in a pin-spacer container. Furthermore, CSS properties like transform, filter, and opacity can create new stacking contexts, making z-index behave in non-intuitive ways.

The investigation revealed two potential blockers:

  1. The pinned #hero-section itself (or its pin-spacer) was sitting on top of everything else, even though its background was transparent.
  2. The underlying #projects-section, which was hidden with opacity: 0, might still be intercepting pointer events before it was formally revealed.

This led to a more sophisticated approach: using the GSAP timeline to manage pointer-events and z-index dynamically.

// In the GSAP timeline...
 
// 1. Disable pointer events on the underlying projects section initially
projects.style.pointerEvents = "none";
 
// 2. When the blackout overlay becomes active, raise the hero's z-index
//    and disable pointer events on the main hero content.
tl.to(blackoutOverlay, {
  opacity: 1,
  onStart: () => {
    hero.style.zIndex = "60"; // Bring the entire hero section forward
    blackoutOverlay.style.pointerEvents = "auto";
  }
});
 
// 3. When the animation completes, hand off pointer events to the projects section.
tl.to(projects, {
  opacity: 1,
  onComplete: () => {
    projects.style.pointerEvents = "auto";
    hero.style.pointerEvents = "none"; // Disable the now-invisible hero section
  }
});

This series of changes ensured that only the relevant container could receive clicks at any given time. The button was finally clickable! But the victory was short-lived.

Chapter 3: The Final Boss (The Reverse-Scrub Bug)

A new, more subtle bug emerged. The user reported: "If I scroll down normally, every card is clickable. But if I scroll from card #3 back to card #2, the button on card #2 is no longer interactive. The clickable area seems to be somewhere below the button."

This was a classic animation state problem. When scrolling forward, our GSAP timeline correctly faded in one card after another. But when scrubbing backward, the fading-out card (card #3) remained in the DOM, transparent but still layered on top of the now-visible card #2. Even though it had opacity: 0, its wrapper was still intercepting the mouse events before they could reach the button on the card underneath.

The last card in the sequence didn't have this problem because there was no subsequent card to linger on top of it.

Chapter 4: The Solution (Frame-Perfect State Management)

The ultimate fix required abandoning event callbacks (onStart, onComplete) for managing interaction state. Those callbacks only fire at the beginning or end of a tween. When a user is scrubbing freely, the timeline can be paused at any point. The state needs to be correct on every single frame.

The solution was to use GSAP's onUpdate callback for the main timeline. This function runs continuously as the timeline's playhead moves.

  1. Create a Centralized State Manager: A helper function, setActiveOverlayWrapper, was created to be the single source of truth for which card is interactive.

    const setActiveOverlayWrapper = (activeIndex: number) => {
      overlayCardEntries.forEach((entry, i) => {
        const isActive = i === activeIndex;
        // Only the active card gets pointer events.
        entry.wrapper.style.pointerEvents = isActive ? "auto" : "none";
        // The active card also gets a higher z-index to sit above its siblings.
        entry.wrapper.style.zIndex = isActive ? "10" : "0";
      });
    };
  2. Calculate the Active Card on Every Tick: Inside the onUpdate callback (updateOverlayProgress), we calculate which card should be active based on the timeline's current time (tl.time()).

    let currentActiveIndex = -1;
     
    const updateOverlayProgress = () => {
      const t = tl.time();
      let active = -1;
     
      // Find which card's time range contains the current time
      for (let i = 0; i < overlayTimes.length; i++) {
        const { start, end } = overlayTimes[i];
        if (t >= start && t <= end) { // Inclusive end for reverse-scrub
          active = i;
          break;
        }
      }
     
      // Only update the DOM if the active card has changed
      if (active !== currentActiveIndex) {
        setActiveOverlayWrapper(active);
        currentActiveIndex = active;
      }
    };
     
    // Register the callback
    tl.eventCallback("onUpdate", updateOverlayProgress);

This approach ensures that, no matter where the user stops scrubbing, only one card is ever interactive. The previously visible card is immediately disabled as the new one becomes active, solving the reverse-scrub issue completely.

Post-Fix Verification

  • We confirmed the overlay CTA logs click events and maintains hover animations when scrubbing forward and backward. (src/components/ui/apple-burst-perfect.tsx)
  • Hero section CTAs and text regained full click/selection behavior after ensuring the ScrollTrigger pin spacer releases pointer events and lowering decorative particle layers (src/components/ui/animated-particles.tsx).
  • Downstream sections (for example, GlobeSection) are now interactable once the blackout overlay fades and projects take over.

Conclusion & Key Takeaways

What seemed like a simple CSS bug turned into a valuable lesson in the architecture of complex, interactive animations.

  • Lesson 1: Clicks Don't Pass Through Ghosts. An element with opacity: 0 is invisible, but it's still there and will block pointer events unless explicitly told not to with pointer-events: none.

  • Lesson 2: Manage State on the Tick, Not the Tween. For scrubbable animations, onStart/onComplete are unreliable. The UI state must be derived directly from the timeline's progress on every frame via onUpdate.

  • Lesson 3: One Interactive Element at a Time. When elements overlap, ensure only one is ever listening for clicks. A centralized function that enforces this rule (setActiveOverlayWrapper) is cleaner and less error-prone than sprinkling state changes throughout the timeline.

  • Lesson 4: Log Everything. When in doubt, add click listeners to every single layer (capture: true is your friend) to build a map of where your events are actually going. This was instrumental in discovering that an invisible sibling card was the true culprit.

CleanLove

Written by CleanLove

Full-stack developer passionate about modern web technologies

Initializing application
Loading page content, please wait...