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:
- The pinned
#hero-section
itself (or its pin-spacer) was sitting on top of everything else, even though its background was transparent. - The underlying
#projects-section
, which was hidden withopacity: 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.
-
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"; }); };
-
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 withpointer-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 viaonUpdate
. -
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.