I was rebuilding a client site's homepage around a "Scenes" direction: one long page whose palette morphs slowly as you scroll. The mechanic is simple on the surface. A single CSS custom property, --site-colour, sits on the root and feeds the background and text colour for the whole page. Each scene owns a colour, and as you scroll, --site-colour gets reset to match whichever scene is on screen.
Mid-page there is a 100svh "project theatre" section that I pinned with ScrollTrigger so it holds in place while its contents animate. Looked clean on paper. The problem only surfaced after that section: the moment I scrolled past the theatre, --site-colour got stuck on a dark value. The About and Why sections below it, which should have been light with dark text, came out dark too. The text was almost unreadable.
What kept this hidden: my numeric DOM probes passed. I had a small check that read the computed --site-colour at a few scroll positions and compared it to what I expected, and it came back green. This bug was caught by a screenshot, not by a number. My eyes found it, the probe did not.
Dead ends
Since a pin was involved, my first guess was the classic one: ScrollTrigger had not re-measured after the layout changed. I called ScrollTrigger.refresh() after all the pins were created. No change. I added invalidateOnRefresh: true, I wrapped the refresh in a requestAnimationFrame, I tried refreshing after assets finished loading. The colour stayed stuck dark past the theatre.
Second guess: maybe a dark class was not being removed, or this was a CSS specificity fight. I traced it. Neither was true. --site-colour really was set to a dark value, and that value really was sticking on :root. So the CSS was correct. What was wrong was when, and at which scroll position, that value got set.
At that point I stopped guessing and started using screenshots as a tool, not just as evidence. I scrolled slowly, capturing every few hundred pixels, then matched each scroll position against the scene that should have been active.
The root cause
The scene colours were driven by positional ScrollTriggers. Each scene had its own trigger with a position-based start and end, roughly like this:
scenes.forEach((scene) => {
ScrollTrigger.create({
trigger: scene,
start: "top center",
end: "bottom center",
onEnter: () => setColour(scene.dataset.colour),
onEnterBack: () => setColour(scene.dataset.colour),
});
});With no pin in the mix, this works. But the moment the theatre section is pinned, ScrollTrigger inserts a pin-spacer and shifts the scroll coordinates for everything below the pin. The colour triggers for About and Why were computed against coordinates that the pin then moved. Their onEnter and onEnterBack landed in the wrong place: the event that was supposed to switch --site-colour back to light as you enter About never fired at the right scroll position. The last colour that got set was the dark one from inside the theatre, and nothing overwrote it at the right time. So it stuck dark.
And that is exactly why my numeric probe passed. The probe read --site-colour at points where it happened to be correct, and the value was always present, never empty. The failure was not "the value is unset", it was "the wrong value is set at the wrong scroll position". A number check does not see that. Only the rendered result does.
The fix
I stopped fighting the pin and threw out the positional approach for colour entirely. I replaced it with a single centre-line scanner: on every scroll frame, read each scene's getBoundingClientRect() and find which scene is crossing the vertical centre of the viewport. That scene decides --site-colour. There is no start or end to recompute, and nothing for a pin to shift.
const scenes = gsap.utils.toArray(".scene");
function updateSiteColour() {
const mid = window.innerHeight / 2;
for (const scene of scenes) {
const rect = scene.getBoundingClientRect();
if (rect.top <= mid && rect.bottom >= mid) {
document.documentElement.style.setProperty(
"--site-colour",
scene.dataset.colour
);
break;
}
}
}
ScrollTrigger.create({
trigger: document.body,
start: 0,
end: "max",
onUpdate: updateSiteColour,
});getBoundingClientRect() returns positions relative to the viewport right now, so a pin-spacer, a changed document height, or anything else that shifts layout stops mattering. The active scene is the one actually in front of you at the centre line. After the swap I re-verified section by section, visually, and About and Why went back to light with dark text.
One note worth recording: I wrote this decision into a do-not-touch register. The centre-line scanner looks like refactor bait. Someone will read it later, decide positional ScrollTriggers are "cleaner", swap it back, and the same dark bug returns. So the reason lives next to the code.
Takeaways
- If a colour or visual state sticks right after a pinned section, suspect the trigger coordinates the pin shifted, not the CSS.
ScrollTrigger.refresh()does not always save you. If the design is fragile to coordinate shifts, change the design.- For "which scene is active", a centre-line scanner using
getBoundingClientRect()per frame is pin-immune by construction, not dependent on positional start and end. - Numeric probes can pass while the render is still wrong. For a colour bug, a screenshot is more honest than a value check.
- If a fix looks like refactor bait, record the reason next to the code so nobody reverts it to the buggy version.
