This is one of those bugs that had me staring at the screen thinking "where did the section go." I was rebuilding a client site as a custom WordPress theme from an older static version, and the homepage had one pinned section in the middle. The moment I scrolled past that pinned section, every section below it disappeared. Not a white blank, but genuinely invisible: text, images, buttons, all gone. The strange part: the links were still hoverable. The cursor turned into a pointer where a button should be, but the visuals were nowhere.
The symptom
The first clue was that hover. If an element were truly display:none or unmounted, you could not hover it. But you could. So the element was in the DOM, taking up space, just not painted. I opened DevTools, inspected a heading that should have been visible, and there was the answer: an inline style="opacity:0" stamped directly on the element. Not from my CSS, not from a class. An inline style, and only one thing was writing inline styles on that element: GSAP.
Every reveal in those sections was built with gsap.from(). The pattern looked roughly like this:
gsap.from(".reveal", {
opacity: 0,
y: 40,
scrollTrigger: {
trigger: ".reveal",
start: "top 80%",
},
});The idea is simple: the element starts at opacity 0 and y 40, then animates to its natural position when the trigger enters the viewport. Locally, on a fast connection, it ran smoothly. In production, the sections below the pin were stuck hidden for good.
Tracing the root cause
Here is the thing about gsap.from() that people rarely internalize: it writes the "from" values as inline styles FIRST, then relies on the tween to animate them back to the natural value. So opacity:0 is not a side effect, it is the starting state, deliberately applied. As long as the trigger fires and the tween runs, opacity climbs back to 1 and the inline style gets cleaned up. The problem: if the trigger NEVER fires, that inline opacity:0 is never removed. The element is locked invisible forever.
Why did the trigger not fire? A combination of two things. First, there was a pinned section above it. Second, inside it sat a partner-logo marquee hotlinked from another domain, so it loaded late. When GSAP computed the start and end positions of every ScrollTrigger, those images had no dimensions yet. Once they loaded, the document height changed, but the already-computed trigger positions went stale. The sections below the pin now had start points that were off by a lot, past a point the scroll would never reach. onEnter was never called, and the opacity:0 was never released.
Worth stressing: this is not the pin-spacer changing height so I have to throw the pin away. I kept the pin. The bug lives purely in the semantics of gsap.from(): it attaches the hidden state as an inline style, and whether the element lives or dies depends on whether the trigger ever removes it.
There was a second, even more annoying variant. My section headings revealed with SplitText, a per-word rise, also through gsap.from(). Elements that had shown up normally would suddenly go faint every time a ScrollTrigger.refresh() ran, for example on resize or when I called refresh manually to fix positions. Same cause, rooted in the same behavior: on refresh, GSAP re-captures the CURRENT value as the new from-value. Since the element was already at its end position, the captured from-value became the end state, and the element got locked halfway, faint.
The fix
The core fix: stop using gsap.from() for scroll-driven reveals. I replaced it with gsap.set() for the initial state, plus gsap.to() with scrollTrigger.once:
gsap.set(".reveal", { opacity: 0, y: 40 });
gsap.to(".reveal", {
opacity: 1,
y: 0,
duration: 0.8,
scrollTrigger: {
trigger: ".reveal",
start: "top 80%",
once: true,
},
});once: true is the key. It fires on the first cross, then the ScrollTrigger stops. No more re-applying the hidden state, no more re-capturing on refresh. And even if a trigger misses because of a stale position, I still had a second safety net.
That net: do not opacity-hide the partner marquee at all. The element most likely to change the height is exactly that late hotlinked image. I let it stay visible from the start via CSS, using a rule that neutralizes the motion state, roughly .has-motion .reveal-partners { opacity: 1 }. That way, even if the JS fails to fire, the element is never invisible.
Third, for the late images, I call ScrollTrigger.refresh() again about 900ms after load, so trigger positions get recomputed once the final dimensions are in:
window.addEventListener("load", () => {
setTimeout(() => ScrollTrigger.refresh(), 900);
});Finally, for the SplitText reveals that went faint, I swapped gsap.from() for gsap.fromTo(). With fromTo, I write both the start and end values explicitly, so ScrollTrigger.refresh() has no room to guess wrong and re-capture the end state as a from-value:
gsap.fromTo(
words,
{ yPercent: 100, opacity: 0 },
{ yPercent: 0, opacity: 1, stagger: 0.05, scrollTrigger: { trigger: heading, once: true } }
);The stack was GSAP 3.13.0 (in this version SplitText and DrawSVGPlugin are now free), enqueued from jsdelivr.
Takeaways
gsap.from()writes the initial state as an inline style and relies on the trigger to remove it. If the trigger never fires, the element is locked invisible.- Invisible but still hoverable means an inline
opacity:0, notdisplay:none. Inspect the element and look for the inline style. - For scroll reveals, use
gsap.set()plusgsap.to({ scrollTrigger: { once: true } }), notgsap.from().onceprevents re-applying the hidden state. - Do not opacity-hide elements that load late (hotlinks, large images). Keep them visible via CSS as a safety net.
- Call
ScrollTrigger.refresh()once more after late assets arrive, so trigger positions do not stay stale. - If
ScrollTrigger.refresh()makes a reveal go faint, replacegsap.from()with an explicitgsap.fromTo().
