D
P
0
← All articles Baca dalam Bahasa Indonesia

JavaScript, DOM & Browser Animation

A Loose Zone plus `onToggle isActive`: The ScrollTrigger Rule That Outlived Its Bug Report

· · 7 min read
A Loose Zone plus `onToggle isActive`: The ScrollTrigger Rule That Outlived Its Bug Report

There was a small mechanism on a client site I worked on last July. The header sits fixed at the very top of the page, and it carries one class that flips its colour while it rides over the hero with the video in it, then returns to a dark colour once the hero is past. That class was added and removed by a ScrollTrigger.

The mechanism is no longer in that site's code. What is left is one line of a rule in my project notes, and that line sits under a list heading reserved for mistakes that must not be repeated.

So this is not a "I hit this bug and then fixed it" story. There are only two things I can actually show you: what the rule says, and the shape of the code that eventually shipped.

The rule really is one line

Exact ScrollTrigger boundaries: use a loose zone ('top bottom', end +headerHeight) + onToggle isActive, not onLeave/onEnterBack at the precise limit.

That is the whole entry, translated from my own notes. Nothing else keeps it company, and there is no description of the symptom at all. The list heading itself reads "Bugs that happened (do not repeat)". So I know something broke once, I know the shape of the fix I settled on, and I no longer know what it actually looked like on screen.

It gets thinner. The same project has a separate file dedicated to GSAP traps, holding entries like a video glitch on Windows and a percentage tween that doubles up, and this exact-boundary bug is not mentioned there at all.

A later derived note filled that gap in, complete with a symptom (the logo turning black while still mid-hero) and a root cause (callbacks misfiring around pixel rounding and refresh). Both live only in that derived note as its author's guess, not as something recorded at the time. One phrase it quoted as if it came from the original notes was not there at all when I went looking. And the broken version never made it into git either: the first appearance of this class in the history is already the loose zone, so there is no diff to hold it up against.

I am leaving that part as it is. The guess may well be right, but guesses written up as findings are exactly what makes old notes stop being trustworthy.

The shape that actually shipped

var headerHeight = header.offsetHeight || 72;
 
var hero = document.querySelector('.hero');
if (hero) {
    header.classList.add('on-hero');
 
    ScrollTrigger.create({
        trigger: hero,
        start: 'top bottom',
        end: 'bottom top+=' + headerHeight,
        onToggle: function (self) {
            header.classList.toggle('on-hero', self.isActive);
        }
    });
}

Three things in there are worth looking at.

headerHeight is not hardcoded. It comes from the header's measured height at that moment, with 72 as a fallback if the measurement comes back zero. The end of the zone is then written relative to that number, bottom top+=headerHeight, rather than to one exact boundary.

The class is added manually first, before the trigger is created. That way the page's initial state is already correct without waiting for any scroll event to arrive.

Then the class is written from self.isActive inside onToggle. The second argument of classList.toggle takes a boolean, so every time this callback runs, the class is rewritten from the trigger's condition right then rather than accumulated from the order of earlier events.

The colour is inherited, the animation belongs to CSS

.page-header {
    position: fixed;
    inset: 0 0 auto;
    z-index: var(--layer-header);
    color: var(--fg-dark);
    transition: color 0.5s var(--ease);
}
 
/* Light over the hero video, dark once past it */
.page-header.on-hero { color: var(--fg-light); }
 
.logo-link { display: block; color: inherit; }

The header's base colour is the dark one, and the swap is animated by a half-second CSS transition, not by a GSAP tween. The class itself touches a single property and is one line long. The logo's wrapper uses color: inherit, and the SVG lockup file was authored with fill="currentColor" on its root svg element. There are only two colours being swapped, #1C1C1C and #FFFFFF.

Which means ScrollTrigger animates nothing here. It only switches one class on and off, and CSS handles the entire colour move.

The rule was never swept through the whole file

In that same function there is a second toggle, for the dark section, and it still uses onEnter, onEnterBack, onLeave, and onLeaveBack. If the rule is read as "never use edge callbacks", my own code breaks it a few lines later.

The correct reading is in the tail of the sentence: "at the precise limit". That second toggle's boundaries are not exact either. Its start and end are functions that push the point half a header height away.

Still in the same file, there is yet another header toggle that does not touch ScrollTrigger at all, just a plain scroll listener with an 8 pixel threshold. And the neighbouring entry in the same bug list has the same flavour: never attach an interval or a safety net that touches a property ScrollTrigger is animating.

Context that no longer applies

While this mechanism was live, the page still used Lenis, so ScrollTrigger there was not running on top of native scroll. Lenis was later pulled from the project entirely, because the 1.1.6 bundle threw "i is not a constructor" in Chrome 150. If I carry this rule to another project, that is the part I have to remember: it was born on a page with a smooth scroll layer, and that layer is gone from the site itself now.

It lived for six days

The logo colour swap landed on 11 July 2026 in theme version 1.6.8, and the commit message calls it dynamic logo color over hero. It was pulled on 17 July 2026 in version 1.8.4, and at HEAD today there is nothing left of it.

It was not a bug that killed it. The client actually praised the mechanism, calling it a "Genius idea", and it was on the do-not-delete list. It was also part of the scroll option the client picked themselves out of two that were offered. What killed it was the client asking for every scroll animation to go, recorded verbatim like this: "remove the animation, no need to scroll or anything, just one page and the footer smaller and visible immediately". The project status notes name the theme swap function among the functions that were deleted, and the header on the front page is now hidden through CSS.

What I took away from these notes