9 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.

Confirm it is a refresh before you fix anything

Guessing wastes an afternoon. Log every refresh, then scroll on a real phone with remote debugging attached:

ScrollTrigger.addEventListener("refreshInit", () => {
  console.log("refresh", window.innerWidth, window.innerHeight);
});

If a line appears every time the address bar moves, a refresh is the problem and the fixes above apply. If nothing logs but the animation still jumps, look at your own resize listeners, or at images and fonts that load late and push content down after ScrollTrigger has already measured.

Turning off resize refreshes entirely

On a page where nothing depends on the viewport size, you can take resize out of the events that trigger a refresh:

ScrollTrigger.config({
  autoRefreshEvents: "visibilitychange,DOMContentLoaded,load",
});

This is a blunt tool. A phone turned from portrait to landscape no longer measures again, so pins and start points stay where they were first calculated. Only use it after testing rotation, and prefer the svh fix where you can.

In React and Next.js: clean up, or refreshes stack up

On client-side navigation, triggers from the previous page stay alive unless something kills them. Each one keeps listening for refreshes and keeps measuring elements that are no longer on the page, so every route change makes the next refresh slower. Set up inside useGSAP, or gsap.context, so everything is reverted when the component unmounts:

import { useGSAP } from "@gsap/react";

useGSAP(
  () => {
    gsap.to(".panel", {
      xPercent: -100,
      scrollTrigger: { trigger: ".track", pin: true, scrub: 1 },
    });
  },
  { scope: containerRef }
);

The same rules apply to the scroll work on i2D and Nammos Hotels, where pinned sections have to hold steady on a phone as well as on a laptop.

A checklist for mobile jank

  • Log refreshInit on a real phone before changing anything.
  • 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.
  • In React or Next.js, create triggers inside useGSAP or gsap.context so they die with the component.
  • Test on a real phone. DevTools device mode does not simulate the address bar.

Related