The hero I built for a premium WordPress theme sounds simple when you describe it out loud. Two panels cover the screen, then split apart like a slit opening as the visitor scrolls. The animation is scrubbed, so the width of the slit tracks scroll position instead of playing on its own fixed duration.
Because the layer behind the panels is dark and the panels themselves are light, the hero type has to switch ink along with it. While the slit is closed, the text is dark so it reads against the panels. Once the slit is fully open, the text turns white so it reads against the dark backdrop. One class does the switching: is-open.
The symptom
Page finishes loading. Nobody has touched the scroll wheel yet. And the hero text is already white.
The panels were still shut, the visible surface was still light, and the white headline sat on top of it, barely legible. Not missing, not faded, just stripped of contrast. No console error, no warning, nothing failing. The animation itself ran perfectly once you scrolled. Only one thing was wrong: the state said "open" while the visuals still said "closed".
The first suspect, and it was clean
The cheapest theory is always the markup. Maybe is-open was baked into the template and never removed. I checked the page source and it was not there. So something was attaching it after JavaScript ran.
Across the whole hero file, exactly one line touched that class, and it lived inside the timeline:
const tl = gsap.timeline({
scrollTrigger: {
trigger: hero,
start: "top top",
end: "+=100%",
scrub: true,
},
});
tl.to(leftPanel, { xPercent: -100, duration: 1 }, 0)
.to(rightPanel, { xPercent: 100, duration: 1 }, 0)
.add(() => hero.classList.add("is-open"), 0.85);The logic reads perfectly reasonable. The timeline runs one second long, so position 0.85 sits exactly 85 percent of the way through. Park a callback there, and when the playhead crosses that point, the class turns on. What I missed was the silent assumption underneath it, namely that the playhead only moves when somebody actually scrolls.
The root cause: refresh is not playback
ScrollTrigger does not only drive the timeline while you scroll. It also refreshes, recalculating start and end positions whenever the layout changes size. That refresh runs when the page first becomes ready, again after assets and fonts land, and again on every viewport resize.
During that measuring pass the timeline playhead gets moved. And a callback sitting on the timeline has no way to tell the difference. From where it stands, "the visitor scrolled to 85 percent" and "ScrollTrigger is re-measuring" are the exact same event: the playhead went past, so run.
So is-open got attached in the first moments of the page's life, long before anyone touched a mouse. And because that callback only ever added the class, with no counterpart to remove it, a single spurious call was enough to wedge the state permanently.
That is the takeaway I kept from this one: a timeline callback is an event, not a state. It answers "something just happened", not "here is the current condition". The ink colour of a hero is plainly a condition rather than an occurrence, so I had been reaching for the wrong tool from the very start.
The fix: read the progress instead of waiting to be called
The answer was not to find some way of suppressing the callback during refresh. It was to delete the callback and derive the state directly from ScrollTrigger's progress on every update:
gsap
.timeline({
scrollTrigger: {
trigger: hero,
start: "top top",
end: "+=100%",
scrub: true,
onUpdate: (self) => {
hero.classList.toggle("is-open", self.progress > 0.85);
},
},
})
.to(leftPanel, { xPercent: -100, duration: 1 }, 0)
.to(rightPanel, { xPercent: 100, duration: 1 }, 0);Note the boolean second argument to classList.toggle. The class is no longer added in one place and quietly hoped away in another. On every update the state is rewritten explicitly from a single number, self.progress.
The side effect is precisely what I needed. When a refresh runs while progress is still 0, toggle receives false and the class comes off. There is no longer an event that can be misread, because nothing is waiting on events anymore. There is only a number being re-read continuously.
Why 0.85 and not 1
The threshold was not a guess. 0.85 is where the slit is visually fully open, which is the point where the dark backdrop genuinely dominates behind the text. Waiting for progress to reach 1 means the ink only flips after the panels have finished moving entirely, and to the eye that is already late: there is a stretch where the dark backdrop dominates while the text is still dark. Because the animation is scrubbed, how long that stretch lasts depends on how fast the visitor scrolls, so there is no fixed duration I can quote. It is visible either way.
Verifying it is easy because the outcome is binary. Closed has to mean dark ink, open has to mean white ink. After this change both were correct straight from load, with no need to scroll first to wake the state up.
Notes
- Callbacks you attach with
.add()on a GSAP timeline also execute during ScrollTrigger refresh, not only when a visitor actually scrolls. Refresh itself runs on load, after assets settle, and on resize. - A callback that only adds a class with no counterpart to remove it is a time bomb. One wrong call and the state is stuck for good.
- If what you are managing is a condition, such as ink colour or a section theme, compute it from
self.progressinsideonUpdate. Do not wait for a playhead to cross a marker. classList.toggle(name, boolean)forces the state to be written explicitly from current conditions, so a refresh or a skipped frame cannot corrupt it.- Pick the progress threshold from the visual moment, not from a round number that looks tidy in code. Mine was 0.85 because that is where the slit is genuinely open.