Back to Blogs

The Two Lines That Make Lenis and GSAP ScrollTrigger Work Together

The Problem Takes About Five Minutes to Find

I added Lenis to my portfolio site to get smooth scroll. Installed it, initialized it, watched the page glide. Looked great.

Then I noticed the About section — which uses a GSAP ScrollTrigger animation to pin the section and run a horizontal reveal — was firing at the wrong scroll position. The pin triggered too early. The animation finished before I'd scrolled anywhere near it.

ScrollTrigger was reading the native window.scrollY. Lenis was running its own interpolated scroll. The two weren't talking to each other.


Why They Conflict by Default

GSAP ScrollTrigger tracks scroll position by listening to the browser's native scroll event and reading window.scrollY. It uses that number to decide when to trigger animations, when to scrub timelines, and when to pin elements.

Lenis works differently. It prevents the browser from scrolling natively and instead drives window.scrollY itself, easing it toward a target position over several frames. At any given moment, window.scrollY might be 150–300px behind where your finger stopped, because Lenis is still gliding there.

ScrollTrigger sees the stale number, not the destination. Animations fire at the wrong time. Pinning feels off. Scrubbed timelines stutter or jump.


The Fix Is Two Lines

Lenis emits a scroll event on every frame it updates. You can pass that directly to ScrollTrigger:

lenis.on('scroll', ScrollTrigger.update)

Every time Lenis moves the scroll position, ScrollTrigger recalculates. They're no longer independent.

The second line is about the animation loop. Lenis has a raf method — its per-frame update function. Normally you'd call it from a requestAnimationFrame loop. But GSAP also runs its own loop via gsap.ticker. If you run both separately, Lenis and GSAP update on the same frame but in an undefined order, which produces micro-stutters.

Drive Lenis from the GSAP ticker instead:

gsap.ticker.add((time) => {
  lenis.raf(time * 1000)
})

Now they update in the same frame, in the right order: Lenis calculates the new scroll position, then GSAP renders animations against it.

One more thing — disable GSAP's lag smoothing, which tries to compensate for frames it thinks were dropped. With Lenis driving the loop, that compensation causes more problems than it solves:

gsap.ticker.lagSmoothing(0)

The Full Composable

Here's the complete integration, shaped as a Vue composable:

import Lenis from '@studio-freight/lenis'
import { gsap } from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'

let lenis: Lenis | null = null

export const useLenis = () => {
  const initLenis = () => {
    if (typeof window === 'undefined' || lenis) return lenis

    lenis = new Lenis({
      duration: 1.2,
      easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
      smoothWheel: true,
      smoothTouch: false,
      touchMultiplier: 2,
    })

    lenis.on('scroll', ScrollTrigger.update)

    gsap.ticker.add((time) => {
      lenis?.raf(time * 1000)
    })

    gsap.ticker.lagSmoothing(0)

    return lenis
  }

  const destroyLenis = () => {
    if (lenis) {
      lenis.destroy()
      lenis = null
    }
  }

  const stopScroll = () => lenis?.stop()
  const startScroll = () => lenis?.start()

  return { initLenis, destroyLenis, stopScroll, startScroll }
}

Notice the guard at the top of initLenis:

if (typeof window === 'undefined' || lenis) return lenis

The window check skips SSR — Nuxt runs on the server, and there's no scroll there. The lenis check prevents double initialization if multiple components call the composable.


Why the Module-Level Variable Matters

lenis is declared outside the composable function, at module scope. This is intentional.

In Vue, every useLenis() call returns a fresh set of functions, but lenis is shared across all callers because it's not reactive state — it's a plain module-level variable. That means:

  • index.vue initializes it in onMounted
  • Header.vue calls stopScroll() from inside the mobile menu
  • Both are touching the same instance

If you moved lenis inside the composable function or into a ref, each caller would get its own variable and lose shared access. The tradeoff is that you can only have one Lenis instance per page — which is what you want anyway.


Stopping Scroll for Modals and Menus

lenis.stop() and lenis.start() are what you call when opening overlays that shouldn't scroll behind them. Here's the pattern from my mobile nav:

const openMenu = () => {
  stopScroll()
  document.body.style.position = 'fixed'
  // run open animation...
}

const closeMenu = () => {
  startScroll()
  document.body.style.position = ''
  // run close animation...
}

You need both. lenis.stop() pauses the smooth scroll, but without position: fixed on the body, users on iOS can still fling the page with native momentum touch scroll. And lenis.start() alone isn't enough — if you forget the body reset, the page locks even after the menu closes.


Things to Watch

Touch devices: smoothTouch: false is the right default. iOS momentum scroll is already smooth and Lenis's touch interpolation tends to fight with it rather than improve it. The touchMultiplier option still lets you tune the sensitivity.

Dynamic content: ScrollTrigger calculates positions once when it initializes. If images or async content loads after mount and changes the page height, those calculations are stale. Call ScrollTrigger.refresh() after the layout settles.

Page navigation in Nuxt: Clean up properly in onUnmounted. Call lenis.destroy() and null the module-level variable. If you skip this, the old Lenis instance keeps running on subsequent pages with the wrong ScrollTrigger contexts, and you'll spend an afternoon debugging animations that work perfectly in isolation.


My portfolio uses this setup for the About section horizontal reveal, the hero parallax, and the project card entrance animations — all running through one Lenis instance, all synchronized to the GSAP ticker. Once you understand why the sync is needed, the fix is obvious. The hard part is realizing the two libraries are in conflict at all.

Enjoyed this?

Share it with your network