A landing page I was building had one showpiece: a hero sequence pinned with GSAP ScrollTrigger and scrubbed against scroll, wrapped in Lenis so the whole page felt smooth and cinematic. The setup was standard, and the integration plumbing followed the official recipe:
const lenis = new Lenis();
lenis.on('scroll', ScrollTrigger.update);
gsap.ticker.add((time) => {
lenis.raf(time * 1000);
});
gsap.ticker.lagSmoothing(0);And the hero section looked roughly like this:
gsap.timeline({
scrollTrigger: {
trigger: '.hero-sequence',
start: 'top top',
end: '+=300%',
pin: true,
scrub: 1,
},
});On desktop, silk. Slow scroll, the sequence glided, not a single dropped frame. Then I opened it on a phone. The pinned section trembled. The element that was supposed to sit locked in the viewport visibly stuttered, sometimes bounced a few pixels, worst right in the middle of the scrub. Not slow, not laggy — jitter: small movements in the wrong direction, repeating, and painfully visible precisely because the element was not supposed to move at all.
The investigation
My first reflex was to blame performance. I shrank the images, disabled other effects, opened the Performance panel. The frame rate was fine. This was not a heavy page; this was a page arguing with itself.
Picking it apart frame by frame surfaced two causes stacked on top of each other.
First, the two-drivers problem. Lenis virtualizes scroll: it listens to input, then moves the page through its own rAF loop with smoothing applied. ScrollTrigger, meanwhile, pins by repositioning the element against the actual scroll position. On desktop with a mouse wheel, the two almost always agree. On mobile, the finger's touch input, Lenis's smoothing, and the pin's repositioning can disagree by a frame here and there. The disagreement is sub-pixel, but on an element that should be perfectly still, sub-pixel disagreements flipping back and forth read to the eye as vibration.
Second, the mobile browser's URL bar. When you scroll on a phone, the browser hides and re-reveals its URL bar, and every time it does, the viewport height changes and a resize event fires. ScrollTrigger refreshes on resize by default, meaning it recalculates every pin position mid-scroll. Each refresh snaps the pin to the freshly computed position. That was the bigger hitch punctuating the fine-grained jitter.
So not one bug but two: a constant sub-pixel dispute, plus a hard snap every time the URL bar moved.
The fix
What actually shipped was simple and slightly bruising to the ego: on that page, Lenis is never initialized on mobile at all. Detect a touch device, and if it is one, let native scrolling do its job. The key realization is that touch scrolling on a phone is already smooth and already physics-correct out of the box; Lenis adds nothing there except a second driver grabbing the wheel. Lenis stays alive on desktop, where it genuinely delivers that cinematic feel.
Plus one line of configuration for the URL bar problem: ScrollTrigger.config() with ignoreMobileResize: true, so URL-bar resizes stop triggering refreshes mid-scroll.
ScrollTrigger.config({ ignoreMobileResize: true });
const isTouchDevice =
window.matchMedia('(hover: none) and (pointer: coarse)').matches;
let lenis = null;
if (!isTouchDevice) {
lenis = new Lenis();
lenis.on('scroll', ScrollTrigger.update);
gsap.ticker.add((time) => {
lenis.raf(time * 1000);
});
gsap.ticker.lagSmoothing(0);
}The result on the phone: the pin locks solid, the scrub tracks the finger without a tremor, and the URL bar is free to come and go without making anything jump. Desktop is completely unchanged.
One important note: the integration plumbing above — lenis.on('scroll', ScrollTrigger.update) plus driving lenis.raf from GSAP's ticker and gsap.ticker.lagSmoothing(0) — is mandatory whenever you do keep Lenis and ScrollTrigger running together. Without it, everything is worse. But do not be fooled: on mobile with pinned sections, that plumbing is necessary but not sufficient. It synchronizes the two drivers; it does not remove the fact that there are two.
The takeaway
Smooth-scroll libraries and pinned scrub compete for the same source of truth: the scroll position. One driver has to win. On desktop, Lenis can win, because wheel events really are coarse and the smoothing feels premium. On mobile, native scroll IS the smooth scroll, so let it win and simply do not init Lenis there. My checklist now for any page with a pin:
First, ignoreMobileResize: true goes in whenever a pin exists, regardless of which scroll library is involved. Second, feature-detect touch and skip the smooth-scroll library on touch devices unless there is a very specific reason not to. Third, and this is the one everyone forgets: test pins on a real phone with the URL bar actually sliding in and out. DevTools emulation never moves the URL bar, so this entire class of bug will never show up on your laptop. It only shows up in the user's hand, on the device most people will open your page with.
