7 min read

ScrollTrigger on Mobile: ignoreMobileResize, normalizeScroll and the Address Bar

An animation can look perfect on desktop and in DevTools device mode, then jump on a real iPhone the first time the user scrolls. The usual cause is the browser's address bar, not your animation code.

What is actually happening

On mobile browsers the address bar hides as you scroll down and returns as you scroll up. Each time it does, the viewport height changes and the browser fires a resize event. ScrollTrigger listens for resize and runs a refresh, which measures every start and end point again. If any of those depend on the viewport height, they move while the user is scrolling. That is the jump you see.

ignoreMobileResize

ScrollTrigger.config({ ignoreMobileResize: true });

On touch devices, this tells ScrollTrigger to skip a refresh when only the height changed and the width stayed the same. That is exactly the address bar case. In current GSAP versions it is already `true` by default. If you still see jumps, something else is causing the refresh, or your own code is reading the viewport height.

The usual culprit: vh in your own code

A section with `height: 100vh`, or an `end` value calculated from `window.innerHeight`, changes size when the address bar moves, whatever ScrollTrigger does. Use the viewport units that were designed for this problem:

.hero {
  /* small viewport: address bar visible, never changes while scrolling */
  height: 100svh;
}

.pinned-stage {
  /* large viewport: address bar hidden */
  min-height: 100lvh;
}

`svh` and `lvh` stay the same size while the address bar moves, so nothing measured from them shifts. Avoid `dvh` on anything a ScrollTrigger measures, because `dvh` exists specifically to change size with the address bar.

normalizeScroll: powerful, and not free

ScrollTrigger.normalizeScroll(true);

This moves scrolling onto the JavaScript thread. It stops the address bar from showing and hiding, and it fixes the iOS jitter where a pinned element shakes during momentum scrolling. The cost is that native scrolling is no longer fully native, so test it on real devices. It is also one more thing competing for the main thread, which can hurt INP on an already busy page. I only turn it on after a real device shows pin jitter that nothing else fixes.

With Lenis: pick one smoothing layer

Lenis and normalizeScroll both take over scrolling, and running both at once causes jitter. If you use Lenis, leave normalizeScroll off and drive both libraries from the same ticker:

const lenis = new Lenis();

lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);

One requestAnimationFrame loop now drives both libraries, so ScrollTrigger reads the same scroll position that Lenis just rendered and nothing lags a frame behind.

A checklist for mobile jank

  • Replace 100vh with 100svh or 100lvh on anything pinned or measured.
  • Make start and end values functions and set invalidateOnRefresh: true, so a real resize measures again correctly.
  • Call ScrollTrigger.refresh() once after late content loads, such as images without dimensions or web fonts.
  • Use one smoothing layer: Lenis or normalizeScroll, never both.
  • Test on a real phone. DevTools device mode does not simulate the address bar.

Related

Got a project with these problems?

Available for remote work worldwide.

Start a conversation →