11 KiB
11 KiB
Lenis React Integration
Complete Lenis smooth scroll integration for React/Next.js with GSAP ScrollTrigger.
Table of Contents
- Installation
- Basic Setup
- GSAP Integration
- useLenis Hook
- Configuration Options
- Common Patterns
- Troubleshooting
Installation
npm install lenis
# Package includes React components at lenis/react
Basic Setup
Provider Component
// components/SmoothScroll.tsx
'use client'
import { ReactLenis } from 'lenis/react'
export function SmoothScroll({ children }: { children: React.ReactNode }) {
return (
<ReactLenis root options={{ lerp: 0.1, duration: 1.2 }}>
{children}
</ReactLenis>
)
}
Layout Integration
// app/layout.tsx
import { SmoothScroll } from '@/components/SmoothScroll'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<SmoothScroll>{children}</SmoothScroll>
</body>
</html>
)
}
Required CSS
/* globals.css */
html.lenis, html.lenis body {
height: auto;
}
.lenis.lenis-smooth {
scroll-behavior: auto !important;
}
.lenis.lenis-smooth [data-lenis-prevent] {
overscroll-behavior: contain;
}
.lenis.lenis-stopped {
overflow: hidden;
}
.lenis.lenis-scrolling iframe {
pointer-events: none;
}
GSAP Integration
Full Integration (Recommended)
// components/SmoothScroll.tsx
'use client'
import { ReactLenis, useLenis } from 'lenis/react'
import { useEffect, useRef } from 'react'
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
gsap.registerPlugin(ScrollTrigger)
function LenisGSAPConnector() {
const lenis = useLenis()
useEffect(() => {
if (!lenis) return
// Connect Lenis scroll to ScrollTrigger
lenis.on('scroll', ScrollTrigger.update)
// Use GSAP ticker for RAF
const update = (time: number) => {
lenis.raf(time * 1000)
}
gsap.ticker.add(update)
gsap.ticker.lagSmoothing(0)
return () => {
gsap.ticker.remove(update)
lenis.off('scroll', ScrollTrigger.update)
}
}, [lenis])
return null
}
export function SmoothScroll({ children }: { children: React.ReactNode }) {
return (
<ReactLenis
root
options={{
lerp: 0.1,
duration: 1.2,
smoothWheel: true,
wheelMultiplier: 1,
touchMultiplier: 2,
syncTouch: false,
}}
>
<LenisGSAPConnector />
{children}
</ReactLenis>
)
}
Alternative: Ref-based Integration
'use client'
import { ReactLenis, type LenisRef } from 'lenis/react'
import { useEffect, useRef } from 'react'
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
gsap.registerPlugin(ScrollTrigger)
export function SmoothScroll({ children }: { children: React.ReactNode }) {
const lenisRef = useRef<LenisRef>(null)
useEffect(() => {
const lenis = lenisRef.current?.lenis
if (!lenis) return
lenis.on('scroll', ScrollTrigger.update)
const update = (time: number) => {
lenis.raf(time * 1000)
}
gsap.ticker.add(update)
gsap.ticker.lagSmoothing(0)
return () => {
gsap.ticker.remove(update)
}
}, [])
return (
<ReactLenis ref={lenisRef} root options={{ lerp: 0.1 }}>
{children}
</ReactLenis>
)
}
useLenis Hook
Basic Usage
'use client'
import { useLenis } from 'lenis/react'
function ScrollProgress() {
const [progress, setProgress] = useState(0)
useLenis(({ scroll, limit, velocity, direction }) => {
setProgress(scroll / limit)
console.log({ scroll, limit, velocity, direction })
})
return (
<div
className="fixed top-0 left-0 h-1 bg-blue-500 z-50"
style={{ width: `${progress * 100}%` }}
/>
)
}
Scroll to Element
'use client'
import { useLenis } from 'lenis/react'
function ScrollToSection() {
const lenis = useLenis()
const scrollToSection = (id: string) => {
const element = document.getElementById(id)
if (element && lenis) {
lenis.scrollTo(element, {
offset: -100, // Offset from top
duration: 1.5, // Animation duration
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
})
}
}
return (
<nav>
<button onClick={() => scrollToSection('about')}>About</button>
<button onClick={() => scrollToSection('work')}>Work</button>
<button onClick={() => scrollToSection('contact')}>Contact</button>
</nav>
)
}
Stop/Start Scroll
'use client'
import { useLenis } from 'lenis/react'
function Modal({ isOpen, onClose, children }) {
const lenis = useLenis()
useEffect(() => {
if (isOpen) {
lenis?.stop()
} else {
lenis?.start()
}
}, [isOpen, lenis])
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50">
{children}
</div>
)
}
Velocity-Based Effects
'use client'
import { useLenis } from 'lenis/react'
import { useRef } from 'react'
import gsap from 'gsap'
function VelocitySkew() {
const textRef = useRef<HTMLHeadingElement>(null)
useLenis(({ velocity }) => {
gsap.to(textRef.current, {
skewY: velocity * 0.05,
duration: 0.3,
ease: 'power2.out',
})
})
return <h1 ref={textRef}>Velocity Skew Text</h1>
}
Configuration Options
Full Options Reference
<ReactLenis
root
options={{
// Smoothing
lerp: 0.1, // Linear interpolation (0-1), lower = smoother
duration: 1.2, // Animation duration in seconds
// Wheel
smoothWheel: true, // Smooth wheel scrolling
wheelMultiplier: 1, // Wheel sensitivity
// Touch
touchMultiplier: 2, // Touch sensitivity
syncTouch: false, // Sync touch with lerp (experimental)
syncTouchLerp: 0.075, // Lerp for sync touch
// Direction
orientation: 'vertical', // 'vertical' | 'horizontal'
gestureOrientation: 'vertical',
// Behavior
infinite: false, // Infinite scroll
autoRaf: false, // Use built-in RAF (false when using GSAP ticker)
// Content
wrapper: window, // Scroll wrapper element
content: document.documentElement,
// Events
eventsTarget: window, // Event listener target
// Misc
prevent: undefined, // Function to prevent scroll on certain elements
virtualScroll: undefined, // Custom virtual scroll handler
}}
>
{children}
</ReactLenis>
Common Presets
// Premium feel (Studio Freight style)
const premiumOptions = {
lerp: 0.075,
duration: 1.5,
smoothWheel: true,
wheelMultiplier: 0.8,
}
// Snappy feel
const snappyOptions = {
lerp: 0.15,
duration: 0.8,
smoothWheel: true,
wheelMultiplier: 1.2,
}
// Mobile-friendly
const mobileOptions = {
lerp: 0.1,
duration: 1.2,
touchMultiplier: 1.5,
syncTouch: true,
syncTouchLerp: 0.075,
}
Common Patterns
Prevent Scroll on Specific Elements
// Add data-lenis-prevent to prevent smooth scroll
<div data-lenis-prevent>
<textarea>Scrollable text area</textarea>
</div>
// Or data-lenis-prevent-wheel for wheel only
<div data-lenis-prevent-wheel>
<div className="horizontal-scroll">...</div>
</div>
Anchor Links
'use client'
import { useLenis } from 'lenis/react'
import { useEffect } from 'react'
function AnchorHandler() {
const lenis = useLenis()
useEffect(() => {
// Handle anchor links
const handleClick = (e: MouseEvent) => {
const target = e.target as HTMLAnchorElement
if (target.hash) {
e.preventDefault()
const element = document.querySelector(target.hash)
if (element) {
lenis?.scrollTo(element as HTMLElement, { offset: -100 })
}
}
}
document.addEventListener('click', handleClick)
return () => document.removeEventListener('click', handleClick)
}, [lenis])
return null
}
Scroll Direction Detection
'use client'
import { useLenis } from 'lenis/react'
import { useState } from 'react'
function DirectionAwareHeader() {
const [isVisible, setIsVisible] = useState(true)
useLenis(({ direction }) => {
setIsVisible(direction <= 0) // Show on scroll up
})
return (
<header
className={`fixed top-0 transition-transform duration-300 ${
isVisible ? 'translate-y-0' : '-translate-y-full'
}`}
>
Header
</header>
)
}
Horizontal Scroll Section with Lenis
'use client'
import { useLenis } from 'lenis/react'
import { useRef, useEffect } from 'react'
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
import { useGSAP } from '@gsap/react'
gsap.registerPlugin(ScrollTrigger)
function HorizontalSection() {
const containerRef = useRef<HTMLDivElement>(null)
const wrapperRef = useRef<HTMLDivElement>(null)
useGSAP(() => {
const sections = gsap.utils.toArray<HTMLElement>('.h-section')
gsap.to(sections, {
xPercent: -100 * (sections.length - 1),
ease: 'none',
scrollTrigger: {
trigger: wrapperRef.current,
pin: true,
scrub: 1,
snap: 1 / (sections.length - 1),
end: () => '+=' + wrapperRef.current!.scrollWidth,
}
})
}, { scope: containerRef })
return (
<div ref={containerRef}>
<div ref={wrapperRef} className="flex" data-lenis-prevent-wheel>
{[1, 2, 3, 4].map(i => (
<div key={i} className="h-section w-screen h-screen flex-shrink-0">
Section {i}
</div>
))}
</div>
</div>
)
}
Troubleshooting
ScrollTrigger Not Working
// Always refresh after Lenis initializes
useEffect(() => {
if (lenis) {
ScrollTrigger.refresh()
}
}, [lenis])
Jerky Scroll on Mobile
// Enable syncTouch for better mobile performance
<ReactLenis
root
options={{
syncTouch: true,
syncTouchLerp: 0.075,
touchMultiplier: 1.5,
}}
>
Scroll Not Working in Modals
// Stop Lenis when modal opens
useEffect(() => {
if (isModalOpen) {
lenis?.stop()
} else {
lenis?.start()
}
}, [isModalOpen, lenis])
Performance Issues
- Disable on low-power mode: Check
navigator.getBattery()for charging state - Reduce lerp value: Higher lerp = less calculations
- Use
autoRaf: trueif not using GSAP ticker
Next.js App Router Specifics
- Always use
'use client'directive - Wrap in layout.tsx for global smooth scroll
- Use
usePathname()to reset scroll on navigation:
'use client'
import { useLenis } from 'lenis/react'
import { usePathname } from 'next/navigation'
import { useEffect } from 'react'
function ScrollReset() {
const lenis = useLenis()
const pathname = usePathname()
useEffect(() => {
lenis?.scrollTo(0, { immediate: true })
}, [pathname, lenis])
return null
}