Skip to article body
A woman standing still inside a Cairo metro carriage while the city outside the windows smears into long streaks of colour
A woman standing still inside a Cairo metro carriage while the city outside the windows smears into long streaks of colour

Web Development · Field Notes

An Identity Matrix Is Still a Transform

How a finished preloader animation kept trapping position: fixed elements on our site.

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.

Share
X LinkedIn Facebook WhatsApp Email
A glass of tea sitting still on a train window ledge while the landscape passes in soft horizontal streaks

Fixed, relative to whatever is carrying it.

Traps it when
Anything except none
Where we would expect it
Finished GSAP and CSS animations
Traps it when
Anything except none
Where we would expect it
Blur or SVG effects on a wrapper
Traps it when
Anything except none
Where we would expect it
3D card and carousel parents
Traps it when
It names one of these properties
Where we would expect it
Performance hints left on a wrapper
Traps it when
layout, paint, strict or content
Where we would expect it
Performance work on long pages

The list covers the cases most likely to appear on a marketing site.

An ancestor is probably acting as its containing block. A transform, filter or perspective other than none will do it, as will will-change naming one of them. Walk up the tree and check computed styles to find it.

Yes. Its computed value is an identity matrix, which is not none, so it still creates a containing block. Remove the property to undo that.

Call gsap.set(element, { clearProps: 'transform' }) when the animation ends, and do it on every path that can end the animation, including cancelled ones.

We build and repair sites with heavy animation, and we like finding out why they break.

More from the Journal

view all →