I built an immersive homepage for a client site: one long pinned section whose color theme shifts as you scroll. The hero is dark, the middle is light, then the services section goes dark again. Every one of those dark and light flips is driven by a single class on body. When scene-dark is on, the background is dark and the text is white; when off, the background is light. As long as theme and text color stay in sync, the transitions look cinematic.
Then a bug showed up that made my jaw drop. Sometimes, when a visitor scrolled up quickly, the hero subtitle simply vanished. Not faded: gone. The hero background had turned white while the subtitle was hardcoded white. White on white. The element was still in the DOM, taking up space, just invisible.
The symptom
The annoying part was that it was intermittent, and the trigger was specific. Scroll down slowly, fine. Scroll up slowly, fine. But scroll up FAST, from the services area back into the hero, and scene-dark would occasionally fall off and leave the hero white. A refresh sometimes fixed it, sometimes not. No console error, no warning. Just missing text.
The dead ends
My first, wrong reflex was to treat it as a color problem: stop hardcoding the subtitle white, make it adaptive. But that only patches the symptom. If scene-dark is supposed to be on in the hero, forcing the text dark breaks the color in the normal case. I held off.
Second guess: a lagging CSS transition on body. I disabled it. Still there. Not it.
Only after I dropped a console.log into every ScrollTrigger callback did the pattern show. On a fast scroll-up, the logs came out in an order I did not expect: the handler that ADDED scene-dark ran first, then a fraction of a second later another handler REMOVED it again. The class was briefly correct, then stripped by a late callback.
Root cause: two ScrollTriggers racing
At that point each section managed its own theme. The hero had a ScrollTrigger with onEnter, onLeave, onEnterBack, onLeaveBack. Services had the same set of edge callbacks. Four callbacks spread across two functions, each busy adding or removing scene-dark at its own section boundary.
During slow scrolling, those boundaries get crossed one at a time in a tidy order, so the callback sequence makes sense. The trouble comes from Lenis smooth scroll: on a fast scroll-up, it can move the scroll position far enough in a single frame that two boundaries get crossed almost at once. And here is the catch: the firing order between leave services and enter hero is not guaranteed to be atomic. The enter hero callback adds scene-dark, then the trailing leave services callback removes it, even though I am now inside the hero. Result: a hero with no scene-dark, a white background, and vanished white text.
No single callback is the bug. The architecture is: edge callbacks store state implicitly through event order. Once that order is not guaranteed, the state corrupts.
The fix: one state controller derived from scroll position
The fix was not to patch the ordering but to delete ordering entirely. I replaced the four scattered ScrollTriggers with ONE that ignores edges completely and recomputes state from scratch on every update:
function initSceneController() {
const HERO_END = 2500;
const SERVICES_START = 9400;
const SERVICES_END = 14000;
const apply = (scroll) => {
const inHero = scroll < HERO_END;
const inServices = scroll >= SERVICES_START && scroll < SERVICES_END;
body.classList.toggle("scene-dark", inHero || inServices);
if (stage) stage.classList.toggle("is-dark", inServices);
};
ScrollTrigger.create({
trigger: "body",
start: "top top",
end: "max",
onUpdate: (self) => apply(self.scroll()),
onRefresh: (self) => apply(self.scroll()),
});
apply(window.scrollY);
}Note the two-argument toggle. scene-dark is on if and only if the scroll position is in the hero or in services. No more add here, remove there. The state is a pure function of one number. No matter how far a frame jumps on a fast scroll-up, the result is correct because it is computed from the current condition, not from accumulated events. The race is gone: there are no longer two parties fighting to write the same class.
This minimal version is what I shipped first. I did feel the pull to rewrite every reveal into one big system at once, but that big-bang refactor is exactly the one I reverted: too much changing at once. Then I generalized slowly.
Generalizing into a full state controller
I lifted the same pattern to ALL text reveals: a small state registry plus a list of phase boundaries, then one function that applies a phase and only fires show or hide when the phase CHANGES:
const TEXT_STATE = { hero, subtitle, problem, approach: [null, null, null] };
const PHASE_BOUNDARIES = [400, 1100, 3400, 5400, 7400, 9400];
const getPhase = (scroll) =>
PHASE_BOUNDARIES.filter((b) => scroll >= b).length;
let currentPhase = -1;
const applyPhase = (scroll) => {
const target = getPhase(scroll);
if (target === currentPhase) return; // only runs when the phase changes
currentPhase = target;
// show/hide text for the target phase
};Because state is always computed from scroll position and actions only run on a phase change, this killed two more bugs: phantom callbacks firing at init, and reveals breaking on scroll-up reversal. All are symptoms of the same disease: storing state in edge callbacks instead of computing it.
Takeaways
- If a theme class only falls off during fast scroll-up, suspect a race between edge callbacks, not CSS or color.
onEnter,onLeave,onEnterBack,onLeaveBackstore state through event order. Smooth scroll like Lenis can cross several boundaries in one frame, and the firing order is not guaranteed atomic.- Replace many edge callbacks with one
onUpdateScrollTrigger that computes state as a pure function of scroll position. No order, no race. - Use
classList.toggle(name, boolean)so the state is always written explicitly from the current condition, not added and removed separately. - A state controller beats edge callbacks: once the scroll number is the single source of truth, phantom init fires and scroll-up reversal bugs disappear as well.
- Do not big-bang refactor. Ship the minimal state controller first, generalize later.
