By Shehab Waleed, Founder, Cairo Studio 3 min read
The services page on our site showed a footer and nothing else. Refresh, and it was perfect. A bug that vanishes on reload reads like a loading problem, and that is how it first looked: a slice that had not loaded.
What the preloader does
On a first visit, our preloader slides the page up into place. It pushes main and nav down, then tweens them back.
gsap.set(slideTargets, { y: '35svh' });
gsap.to(slideTargets, { y: '0svh' });When the tween ends, the page looks untouched. The computed style says otherwise.
getComputedStyle(main).transform;
"matrix(1, 0, 0, 1, 0, 0)"
That matrix moves nothing, but it is not none, and CSS has no exception for transforms that happen to do nothing. Any value other than none makes the element the containing block for its position: fixed descendants.
Why the page went blank
The services slice is rooted at fixed inset-0, and it was the only child of main. A fixed element leaves the flow, so main had a height of zero. With main as its containing block, inset: 0 resolved against that zero, and the section collapsed with it. We reproduced it on a bare page to be sure: with the identity matrix the fixed section measures 0px, and without it, the full 600px of the viewport.
A reload skips the preloader, since it only runs once per session, so the page rendered correctly and hid the evidence. To reproduce this kind of bug, clear sessionStorage before you reload. A plain refresh will never show it.
Finding the ancestor
You can ask the browser directly. Select the stuck element in DevTools, so it becomes $0, and walk up from it.
const props = ['transform', 'filter', 'perspective'];
let el = $0;
while ((el = el.parentElement)) {
const s = getComputedStyle(el);
for (const prop of props) {
if (s[prop] !== 'none') console.log(el, prop, s[prop]);
}
if (s.contain !== 'none') console.log(el, s.contain);
if (s.willChange !== 'auto') console.log(el, s.willChange);
}
The culprit prints immediately.
The fix
A clearProps on one tween is not enough. The preloader changes shared elements across several exit paths, including cancelled runs, and any branch that forgets to restore them brings the bug back. One function does the job, and both the timeline completion and the effect cleanup call it.
const releaseSlideTargets = () => {
gsap.set(slideTargets, { clearProps: 'transform' });
};It came back twice
The contact bubble
Our floating contact form lives inside a slice, which means inside main. On phones, while the slide was running, the bubble sat about 190px too high and then jumped into place when the transform cleared. That cost us a CLS of 0.30 on the contact page. It now mounts after the preloader finishes.
preloaderStore.whenComplete().then(() => (formReady = true));
The project universe
Our three.js universe is a fixed section too. It measured itself while main was still transformed, read a collapsed height, and sized its pool of image planes for an aspect of 390 to 1440. That meant 7,735 planes on a phone and 28,483 on a laptop. A collapsed container now measures against window.innerHeight instead.
The rules we keep
- Test any fixed element on a real first visit, with sessionStorage cleared.
- Clear a transform when you are done with it. A tween back to zero does not remove it.
- Refresh cached getBoundingClientRect values after the slide, because transforms fire no scroll event.
The same trap ruled out GSAP pinning on our touch layouts, since a pin uses position: fixed.
