Реализация Locomotive Scroll / Lenis для плавного скролла
Locomotive Scroll и Lenis — две библиотеки, которые реализуют «масляный» скролл: скроллится не страница, а происходит интерполяция между текущей и целевой позицией. Результат — кинематографическое движение, которое можно синхронизировать с GSAP ScrollTrigger.
Выбор между ними: Lenis проще, легче, активно поддерживается (Darkroom/Studio Freight). Locomotive Scroll v2 тяжелее, но имеет встроенный parallax через data-speed. Для новых проектов Lenis — предпочтительный выбор.
Lenis: базовая установка
npm install lenis
import Lenis from 'lenis'
const lenis = new Lenis({
duration: 1.2, // длительность одного "шага" скролла
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)), // expo out
orientation: 'vertical',
gestureOrientation: 'vertical',
smoothWheel: true,
touchMultiplier: 2, // чувствительность на тач
infinite: false,
})
// RAF loop — Lenis требует вызова raf() каждый кадр
function raf(time: number) {
lenis.raf(time)
requestAnimationFrame(raf)
}
requestAnimationFrame(raf)
Интеграция Lenis + GSAP ScrollTrigger
Это основной кейс использования: Lenis управляет скроллом, ScrollTrigger — анимациями привязанными к позиции.
import Lenis from 'lenis'
import gsap from 'gsap'
import ScrollTrigger from 'gsap/ScrollTrigger'
gsap.registerPlugin(ScrollTrigger)
const lenis = new Lenis()
// Критично: без этого ScrollTrigger будет работать по нативному scrollY,
// а не по виртуальному скроллу Lenis
lenis.on('scroll', ScrollTrigger.update)
gsap.ticker.add((time) => {
lenis.raf(time * 1000) // gsap ticker даёт время в секундах
})
gsap.ticker.lagSmoothing(0) // отключить lag smoothing GSAP
// Теперь ScrollTrigger работает корректно с Lenis
gsap.to('.hero-title', {
scrollTrigger: {
trigger: '.hero',
start: 'top top',
end: 'bottom top',
scrub: 1,
},
y: -100,
opacity: 0,
})
Parallax через Lenis
Lenis сам по себе не делает parallax — только плавный скролл. Parallax-эффекты строятся поверх через ScrollTrigger или кастомный RAF:
// Parallax без GSAP — через прямое обновление transform
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, // коэффициент интерполяции (чем меньше — тем плавнее)
smartphone: {
smooth: false, // отключить на мобилке (нативный скролл)
},
tablet: {
smooth: false,
},
})
// Необходимо вызывать при изменении высоты контента
scroll.update()
HTML-структура для Locomotive:
<main data-scroll-container>
<section data-scroll-section>
<h1 data-scroll data-scroll-speed="2">Заголовок</h1>
<!-- Sticky элемент -->
<div data-scroll data-scroll-sticky data-scroll-target="#section">
Sticky sidebar
</div>
<!-- Параллакс изображение -->
<img
data-scroll
data-scroll-speed="-1"
data-scroll-position="top"
src="photo.jpg"
/>
</section>
</main>
React-интеграция Lenis
import { useEffect, useRef } from 'react'
import Lenis from 'lenis'
// Singleton через 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)
}
// Программная прокрутка из любого компонента
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>
}
Остановка/возобновление скролла
Нужно для модальных окон, меню-оверлеев:
// Lenis
lenis.stop() // заблокировать скролл
lenis.start() // разблокировать
// Locomotive Scroll
scroll.stop()
scroll.start()
Производительность и pitfalls
ResizeObserver — оба решения отслеживают высоту контента через ResizeObserver. При динамической загрузке контента (lazy images, аккордеоны) нужно вызывать lenis.resize() или scroll.update() после изменений.
iOS Safari — на iOS нативный scroll имеет особую физику (bounce). Lenis с smoothTouch: false (по умолчанию) оставляет тач-скролл нативным, что правильно.
Вложенные скроллируемые контейнеры — модалки, сайдбары со своим overflow. Нужно останавливать Lenis при входе в такой контейнер.
will-change: transform на parallax-элементах — переводит их на отдельный compositor layer, снижает перерасчёт layout.
Сроки
Lenis с базовым скроллом и ScrollTrigger анимациями — 1 день. Locomotive Scroll с parallax-данными, мобильным fallback и интеграцией в React-проект — 2–3 дня.







