Skip to article body
A man letting a felucca drift on the Nile at blue hour, the white sail smeared into soft streaks
A man letting a felucca drift on the Nile at blue hour, the white sail smeared into soft streaks

Web Development · Field Notes

Stop Moving Content Against the Finger

Three tries at a scroll-linked project deck on iPhone, and why CSS sticky won.

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.

Share
X LinkedIn Facebook WhatsApp Email
Fingers trailing through dark river water from a wooden boat, the water stretched into long streaks

Native momentum, left alone.

Safe on touch
CSS sticky cards
Gated off on touch
A deck positioned from JavaScript
Safe on touch
position: sticky with overflow-clip
Gated off on touch
GSAP pin, which uses position: fixed
Safe on touch
A static image
Gated off on touch
Parallax driven by scroll position
Safe on touch
Scrubbed reveals that stay in place
Gated off on touch
Content that travels against the scroll
Safe on touch
Horizontal only movement, like a marquee
Gated off on touch
Touch scroll taken over by JavaScript

iOS handles touch scrolling natively and tells the page about it afterwards. Anything you position from a scroll event is reacting to a position that has already been drawn, so it arrives a frame or two late.

We removed it. It fixed the lag but shortened momentum enough that people had to swipe several times to move through a page. Test it with real fingers on your own layout before you keep it.

Look for overflow: hidden on an ancestor, including the body. On our site, switching to overflow: clip kept the horizontal clipping and let sticky work.

We will look at what is moving, what is fighting the browser, and what can simply be removed.

More from the Journal

view all →