Implementing Locomotive Scroll / Lenis for Smooth Scroll
Locomotive Scroll and Lenis are two libraries implementing "oiled" scroll: not the page scrolls, but interpolation occurs between current and target position. Result is cinematic movement that can synchronize with GSAP ScrollTrigger.
Choice between them: Lenis is simpler, lighter, actively maintained (Darkroom/Studio Freight). Locomotive Scroll v2 is heavier but has built-in parallax via data-speed. For new projects, Lenis is the preferred choice.
Lenis: Basic Setup
npm install lenis
import Lenis from 'lenis'
const lenis = new Lenis({
duration: 1.2, // duration of one "step" of scroll
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)), // expo out
orientation: 'vertical',
gestureOrientation: 'vertical',
smoothWheel: true,
touchMultiplier: 2, // touch sensitivity
infinite: false,
})
// RAF loop — Lenis requires raf() call every frame
function raf(time: number) {
lenis.raf(time)
requestAnimationFrame(raf)
}
requestAnimationFrame(raf)
Lenis + GSAP ScrollTrigger Integration
This is the main use case: Lenis manages scroll, ScrollTrigger animates tied to position.
import Lenis from 'lenis'
import gsap from 'gsap'
import ScrollTrigger from 'gsap/ScrollTrigger'
gsap.registerPlugin(ScrollTrigger)
const lenis = new Lenis()
// Critical: without this ScrollTrigger will use native scrollY,
// not Lenis virtual scroll
lenis.on('scroll', ScrollTrigger.update)
gsap.ticker.add((time) => {
lenis.raf(time * 1000) // gsap ticker gives time in seconds
})
gsap.ticker.lagSmoothing(0) // disable GSAP lag smoothing
// Now ScrollTrigger works correctly with Lenis
gsap.to('.hero-title', {
scrollTrigger: {
trigger: '.hero',
start: 'top top',
end: 'bottom top',
scrub: 1,
},
y: -100,
opacity: 0,
})
Parallax via Lenis
Lenis itself doesn't do parallax—only smooth scroll. Parallax effects build on top via ScrollTrigger or custom RAF:
// Parallax without GSAP — direct transform update
const parallaxItems = document.querySelectorAll<HTMLElement>('[data-parallax]')
lenis.on('scroll', ({ scroll }) => {
parallaxItems.forEach((el) => {
const speed = parseFloat(el.dataset.parallax || '0.3')
const rect = el.getBoundingClientRect()
const center = rect.top + rect.height / 2 - window.innerHeight / 2
el.style.transform = `translateY(${center * speed}px)`
})
})
Locomotive Scroll v2
npm install locomotive-scroll
import LocomotiveScroll from 'locomotive-scroll'
import 'locomotive-scroll/dist/locomotive-scroll.css'
const scroll = new LocomotiveScroll({
el: document.querySelector('[data-scroll-container]') as HTMLElement,
smooth: true,
multiplier: 1,
lerp: 0.08, // interpolation coefficient (smaller — smoother)
smartphone: {
smooth: false, // disable on mobile (native scroll)
},
tablet: {
smooth: false,
},
})
// Must call on content height change
scroll.update()
HTML structure for Locomotive:
<main data-scroll-container>
<section data-scroll-section>
<h1 data-scroll data-scroll-speed="2">Title</h1>
<!-- Sticky element -->
<div data-scroll data-scroll-sticky data-scroll-target="#section">
Sticky sidebar
</div>
<!-- Parallax image -->
<img
data-scroll
data-scroll-speed="-1"
data-scroll-position="top"
src="photo.jpg"
/>
</section>
</main>
React Integration Lenis
import { useEffect, useRef } from 'react'
import Lenis from 'lenis'
// Singleton via React context
import { createContext, useContext } from 'react'
const LenisContext = createContext<Lenis | null>(null)
export function LenisProvider({ children }: { children: React.ReactNode }) {
const lenisRef = useRef<Lenis | null>(null)
useEffect(() => {
const lenis = new Lenis({
duration: 1.2,
easing: (t) => Math.min(1, 1.001 - 2 ** (-10 * t)),
})
lenisRef.current = lenis
let rafId: number
function raf(time: number) {
lenis.raf(time)
rafId = requestAnimationFrame(raf)
}
rafId = requestAnimationFrame(raf)
return () => {
cancelAnimationFrame(rafId)
lenis.destroy()
}
}, [])
return (
<LenisContext.Provider value={lenisRef.current}>
{children}
</LenisContext.Provider>
)
}
export function useLenis() {
return useContext(LenisContext)
}
// Programmatic scroll from any component
function NavLink({ href }: { href: string }) {
const lenis = useLenis()
const handleClick = (e: React.MouseEvent) => {
e.preventDefault()
const target = document.querySelector(href)
if (target && lenis) {
lenis.scrollTo(target as HTMLElement, {
offset: -80,
duration: 1.5,
})
}
}
return <a href={href} onClick={handleClick}>...</a>
}
Stopping/Resuming Scroll
Needed for modals, overlay menus:
// Lenis
lenis.stop() // block scroll
lenis.start() // unblock
// Locomotive Scroll
scroll.stop()
scroll.start()
Performance and Pitfalls
ResizeObserver — both solutions track content height via ResizeObserver. On dynamic content load (lazy images, accordions) need to call lenis.resize() or scroll.update() after changes.
iOS Safari — on iOS native scroll has special physics (bounce). Lenis with smoothTouch: false (default) leaves touch-scroll native, which is correct.
Nested scrollable containers — modals, sidebars with own overflow. Need to stop Lenis when entering such container.
will-change: transform on parallax elements — moves them to separate compositor layer, reduces layout recalc.
Timeline
Lenis with basic scroll and ScrollTrigger animations — 1 day. Locomotive Scroll with parallax data, mobile fallback and React integration — 2–3 days.







