lenis-smooth-scroll
Lenis Smooth Scroll
When to use this
- You want inertial/eased scrolling (the "feel" of native trackpad momentum, amplified) on desktop marketing sites, portfolios, or product pages.
- You are driving GSAP ScrollTrigger, a parallax system, or any scroll-linked animation and need one authoritative scroll value instead of fighting the browser's native scroll event timing.
- You need smooth anchor-link scrolling (
#section-2) with custom easing instead ofscroll-behavior: smooth's fixed, non-configurable curve. - You want horizontal-drives-vertical or virtual scroll setups (see
horizontal-scroll-gallery) that need a controllable scroll proxy. - Do NOT use this when the content is simple, mostly text, or accessibility/SEO-sensitive with no animation requirement, or when you only need scroll-triggered CSS effects with no JS budget: use
css-scroll-driven-animationsinstead, since native scroll-timeline costs zero JS and never fights the browser's own scrolling.
Mental model
Lenis does not scroll the page itself in the traditional sense. It intercepts wheel/touch input, accumulates a velocity value, and on every animation frame lerps the "virtual" scroll position toward a target using lerp(current, target, easing). It then either (a) sets window.scrollTo directly (native mode) or (b) transforms a wrapper element with translate3d (transform mode, used when you need it to work inside overflow: hidden containers or alongside position: sticky pinning tricks). Native mode is default and preferred: it keeps native scrollbar, native find-in-page, and native accessibility scroll behavior, while Lenis only smooths the rate at which scrollY changes.
Critically, Lenis owns the RAF loop. If you already run GSAP's ticker or Three.js's render loop, you must NOT run two independent requestAnimationFrame loops that both write scroll-dependent state, or you get a frame of lag/jitter (one loop reads stale scroll, the other fresh). The correct pattern is: one RAF driver (usually gsap.ticker) calls lenis.raf(time) every frame, and everything else (ScrollTrigger, custom parallax) reads from lenis.scroll or subscribes to Lenis's scroll event, all inside that same tick.
The other thing to internalize: smoothing scroll means the visual scroll position lags behind the input by design (that's the whole point), so ScrollTrigger's start/end markers must be computed against the smoothed scroll, not raw wheel delta. GSAP's ScrollTrigger.scrollerProxy exists exactly to tell ScrollTrigger "here is how to read/set scroll on this custom scroller."