By Shehab Waleed, Founder, Cairo Studio 3 min read
The process section on our studio page is a horizontal track of steps. On desktop you scroll through it. On a phone you swipe, and a progress bar fills as you go. For three weeks after launch, phones got none of that. Nothing moved, nothing threw an error, and desktop worked, so nobody noticed.
The code looked complete
We set the animation up with gsap.matchMedia(), using the conditions form so one function can handle both layouts.
mm.add(
{
isDesktop: '(min-width: 1025px)',
reduceMotion: '(prefers-reduced-motion: reduce)'
},
(ctx) =>
ctx.conditions.isDesktop
? desktop(ctx.conditions.reduceMotion)
: mobile(ctx.conditions.reduceMotion)
);
Read top to bottom, it covers every case. Desktop gets one function and everything else gets the other. That reading assumes the callback always runs.
It runs only when something matches
GSAP calls a conditions callback when at least one query in the object matches, and again whenever the set of matches changes. On a phone 390 pixels wide, with reduced motion off, isDesktop is false and reduceMotion is false. Nothing matches, so the function is never called and the ternary never gets the chance to pick mobile().
There was no error to find. The section still rendered, and the desktop version kept working on the screens we built it on.
One line
Every branch inside the callback needs a query that is true when that branch should run. We added one for phones.
{
isDesktop: '(min-width: 1025px)',
isMobile: '(max-width: 1024px)',
reduceMotion: '(prefers-reduced-motion: reduce)'
}We checked both versions in headless Chromium before writing this. At 390 pixels wide, the old object never called the function, and the new one called it and chose mobile. At 1440 pixels both chose desktop.
Why it hid so well
Three things kept it out of sight. We develop on wide screens, where the code works. The fallback for no animation is a layout that looks deliberate. And a phone with reduced motion switched on makes reduceMotion match, so the callback does run there, and the bug only shows on the phones where motion is allowed.
How we check phone animation now
Desktop is easy to judge by eye. For phones we load the page in WebKit with an iPhone viewport and look for the inline styles GSAP writes. If an element that should animate has nothing written on it after load, the setup did not run. A check like this one is enough:
const ran = await page.evaluate(() =>
[...document.querySelectorAll('.step')]
.some((el) => el.style.transform !== '')
);
Touch needs one more step. Simulated touch through safaridriver did not reach the page in the iOS Simulator for us, so our tests dispatch pointer events with pointerType set to touch from inside the page.
Phones were also where our scroll linked rows fell behind the finger. It is a different bug with the same lesson: check phones on their own terms.
