By Shehab Waleed, Founder, Cairo Studio 3 min read
The projects page on our site is a deck. As you scroll, each project rises over the one before it, while rows drift and follow with small offsets of their own. Every row is positioned from JavaScript on each scroll event. With a mouse it feels great. On iPhone, revealing the next project felt slow and sticky.
Why it lags
On desktop, Lenis drives the wheel scroll inside the same requestAnimationFrame as our layout code, so the scroll position and the row positions always agree. On iOS, touch scrolling runs natively, outside the page. By the time our code hears about the new position and writes a transform, the page has already moved. Rows trail the finger by one or two frames, and you can feel it.
Attempt one: take over the touch scroll
Lenis has a syncTouch option that drives touch scrolling from JavaScript as well. The lag went away, and so did the feel of an iPhone. Its fling uses touchInertiaExponent, which defaults to 1.7, and it travelled about a third as far as native momentum. The first feedback was blunt: we had to swipe three times to pass a project. We reverted it the same day.
Attempt two: a sticky stage
Next we held the deck in a sticky stage, so the browser would keep the container in place. JavaScript still moved every row inside it, so the lag stayed.
This round did expose a second problem. The full width phone poster had made rows around 719px tall, which is taller than the point where the next row opens, at 0.77 of the viewport height. That left a band in each project where nothing moved at the speed of the finger, only a small drift and follow. The page felt as if it had caught your scroll and was holding it.
Attempt three: no JavaScript on touch
Stacking cards is something CSS can do alone. On a coarse pointer, the component skips its layout code and every row becomes sticky.
export const touchScroll = () =>
window.matchMedia('(pointer: coarse)').matches;
mode = reduced ? 'flat' : touchScroll() ? 'cards' : 'deck';
<li class={mode === 'cards' ? 'sticky top-0' : 'relative'}>
...
</li>The browser now does the stacking as part of the scroll itself, so there is nothing left to trail behind it. Mouse users keep the deck, and people who ask for reduced motion get a flat list.
Two details that decide whether sticky works
- The body needs overflow-x: clip. Using hidden there kills sticky on iOS Safari.
- A section that holds sticky children uses overflow-clip for the same reason.
The rule we kept
We audited the rest of the site the same day and wrote down one rule: never move content against the scroll from JavaScript under native touch scrolling. A GSAP pin is out as well, since it relies on position: fixed, which our preloader's transform traps.
This does not mean a lag free JavaScript deck is impossible to imagine. It would need motion that runs on the compositor, which is what sticky already gives us. For this page, removing code was the better trade.
