I was polishing a homepage for a client site, a premium WordPress theme built around a "scenes" concept. The idea was cinematic: the whole page recolors slowly as you scroll. Cross a threshold and the background morphs from light to dark and back, eased over roughly .8 seconds. It looked great in the demo. The problem only showed up during QA: on the light scenes, the headline and body text vanished. Not blurred, not covered by another element, just gone, like white ink spilled on white paper.
Scroll back up to a dark scene and the text reappeared intact. So this was clearly tied to the color morph, not to a font or a layout issue. The text was white, and white on near-white simply does not read.
The symptom
I started with the easy check. Opened DevTools, inspected the "missing" headline, and its color was a flat color: #fff, hardcoded straight into the scene CSS. Meanwhile the body background on a light scene sat around rgb(224, 224, 224). White on very light gray, contrast near zero. That explained the light scenes with unreadable white text.
But there was a second, more confusing symptom. Scrolling slowly across the morph threshold, there was a brief window, a fraction of a second, where the text and the background were both dark. Black on black. The text disappeared again, but this time while moving into a dark scene. Two symptoms that seemed to contradict each other: invisible on light, and blinking out right in the middle of the transition.
Dead ends
My first guess was the wrong tree. I thought it was stacking: maybe a gradient overlay rode over the text during the morph. I checked z-index, I disabled overlays one by one. Text still gone. I suspected a mix-blend-mode on some layer, so I stripped every blend mode. Still gone. I even briefly blamed an animated opacity, though the text opacity was a steady 1 the whole time.
The "quick fix" I tried next actually made the second symptom worse. I figured it was trivial: just drive the text color from the same class that toggles the color scheme. The morph was controlled by a GSAP ScrollTrigger that pins a scheme-dark class onto the html element as the scroll passes the 50% thresholds. So I added a rule: if html.scheme-dark, text is white, otherwise text is dark. The endpoints were now correct. Dark text on light scenes, white text on dark scenes.
But the transition between them fell apart. The body background carried transition: background-color .8s ease-in-out, so it eased slowly over .8 seconds. The text color I just added had no transition at all, so it snapped instantly the moment the class flipped. The result: the text jumped straight to white on the first frame while the background was still halfway through the light color. For those .8 seconds the two did not match. That was the black-on-black and white-on-light flicker I was seeing. Right endpoints, wrong in-between frames.
The root cause
At this point the cause was clear. The text color was static while the background color was animated. As long as those two colors are driven by different timing, they will desync during the transition window. It does not matter that the endpoints are right. What makes this bug visible is that the morph is slow and on-screen: .8 seconds is long enough for the eye to catch a mismatched frame.
The rule is simple but easy to miss: if a component animates its background, every foreground color on top of it has to transition with the same trigger and the same timing. It is not the destination that has to be right, it is the whole path in between.
The fix
The key: drive the text color from the exact same trigger as the background, and give it an identical transition duration. The background was already triggered by the html.scheme-dark class at .8s ease-in-out, so the text had to mirror that precisely.
body {
background-color: rgb(224, 224, 224);
transition: background-color 0.8s ease-in-out;
}
html.scheme-dark body {
background-color: rgb(33, 33, 33);
}
.scene--ink-light {
color: rgb(45, 42, 42);
transition: color 0.8s ease-in-out;
}
html.scheme-dark .scene--ink-light {
color: rgb(255, 255, 255);
}Now, the moment ScrollTrigger flips the class, the background and the text move in lockstep. They leave on the same frame, with the same easing, and land on the same frame .8 seconds later. The desync window is gone because there is literally no timing gap left to see.
ScrollTrigger.create({
trigger: ".scene--dark",
start: "top 50%",
end: "bottom 50%",
onToggle: (self) =>
document.documentElement.classList.toggle("scheme-dark", self.isActive),
});One more step made this clean for the long run. Decorative elements like dots, border lines, links, and em each carried their own hardcoded color. Every one of those was a desync bug waiting its turn. I switched them all to inherit the scene ink through currentColor and opacity instead of storing a color of their own.
.scene__dot,
.scene__border,
.scene a,
.scene em {
color: currentColor;
border-color: currentColor;
opacity: 0.7;
}That leaves exactly one ink color per scene, and every element rides the same single transition. I measured the result in DevTools: before the morph, background rgb(224, 224, 224) with ink rgb(45, 42, 42). After the morph, background rgb(33, 33, 33) with ink rgb(255, 255, 255). Healthy contrast at both ends, and smooth in between.
Takeaways
- If a component animates its background color, every foreground color on top of it must transition with the same trigger and the same timing. Correct endpoints are not enough.
- Do not let the text color snap instantly while its background eases slowly. Any timing gap produces a mismatch flicker in the transition window.
- Keep the durations identical: the body background transition and the scene ink color transition must both stay
.8s ease-in-out. Desyncing them brings the bug back. - Use
currentColorplusopacityfor decorative bits so they inherit one animated ink instead of each hardcoding a color. - Test mid-scroll, not just at the top and bottom of a scene. This bug lives in the transition window, not at the endpoints.
