Реалізація 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, // чутливість на touch
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>
<!-- Parallax зображення -->
<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 (за замовчуванням) залишає touch-скролл нативним, що правильно.
Вкладені скроллюючі контейнери — модалки, сайдбари зі своїм overflow. Потрібно зупиняти Lenis при вході в такий контейнер.
will-change: transform на parallax-елементах — переводить їх на окремий compositor layer, знижує перерахунок layout.
Терміни
Lenis з базовим скроллом та ScrollTrigger анімаціями — 1 день. Locomotive Scroll з parallax-даними, мобільним fallback та React-інтеграцією — 2–3 дні.







