Integrate Lenis smooth scroll into React and Next.js projects. Use when the user wants smooth scrolling, scroll-linked animations, parallax effects, GSAP ScrollTrigger sync, Framer Motion sync, programmatic scroll-to navigation, or scroll event listening in a React app. Covers ReactLenis provider setup, useLenis hook, custom scroll containers, SSR/Next.js considerations, and accessibility.
Lenis (Latin: "smooth") is a lightweight, performant smooth-scroll library by darkroom.engineering. The lenis/react sub-package provides a <ReactLenis> context provider and a useLenis hook, eliminating prop-drilling and tying the RAF loop into React's lifecycle automatically.
Package landscape (important — use the right import):
| Package | Status | Import |
|---|---|---|
| lenis | ✅ Current (v1+) | import { ReactLenis, useLenis } from 'lenis/react' |
| @studio-freight/lenis | ⚠️ Legacy | import Lenis from '@studio-freight/lenis' |
| @studio-freight/react-lenis | ⚠️ Legacy | import { ReactLenis } from '@studio-freight/react-lenis' |
Always use the current lenis package unless the project explicitly pins to the legacy packages.
npm install lenis
# or
yarn add lenis
# or
pnpm add lenis
Also add Lenis's required CSS (critical for correct layout):
/* In your global CSS file */
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-smooth iframe {
pointer-events: none;
}
Or import from the package directly:
import 'lenis/dist/lenis.css'
// app/layout.tsx OR src/main.tsx
import { ReactLenis } from 'lenis/react'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<ReactLenis root>
{children}
</ReactLenis>
</body>
</html>
)
}
root prop: Lenis hijacks the <html> scroll container (the standard full-page case).autoRaf is not disabled.Pass options via the options prop on <ReactLenis>:
<ReactLenis
root
options={{
lerp: 0.1, // Smoothing factor (0–1). Lower = smoother but slower. Default: 0.1
duration: 1.2, // Scroll animation duration in seconds (ignored if lerp set)
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)), // Custom easing
orientation: 'vertical', // 'vertical' | 'horizontal'
gestureOrientation: 'vertical', // 'vertical' | 'horizontal' | 'both'
smoothWheel: true, // Smooth wheel events (default: true)
smoothTouch: false, // Smooth touch scroll — keep false unless intentional (can feel unnatural)
touchMultiplier: 2, // Touch sensitivity
infinite: false, // Infinite scroll
}}
>
{children}
</ReactLenis>
Decision guide:
lerp: 0.08–0.12 feels natural.lerp: 0.05–0.08 for extra drag.smoothTouch: true without thorough iOS testing — can feel laggy.lerp over duration unless you need deterministic timing (e.g., carousel snapping).useLenis returns the Lenis instance and optionally registers a scroll callback:
import { useLenis } from 'lenis/react'
function ScrollTracker() {
// Fires every scroll frame
const lenis = useLenis(({ scroll, limit, velocity, direction, progress }) => {
console.log('scroll progress:', progress) // 0–1
})
// Programmatic scroll-to
const scrollToTop = () => lenis?.scrollTo(0, { duration: 1.5 })
const scrollToEl = () => lenis?.scrollTo('#section-2', { offset: -80 })
return <button onClick={scrollToTop}>Back to top</button>
}
useLenis with no callback just returns the instance — useful for imperative control.
import { useLenis } from 'lenis/react'
function NavLink({ target, children }: { target: string; children: React.ReactNode }) {
const lenis = useLenis()
return (
<a
href={target}
onClick={(e) => {
e.preventDefault()
lenis?.scrollTo(target, {
offset: -80, // Adjust for sticky header height
duration: 1.2,
easing: (t) => 1 - Math.pow(1 - t, 4),
lock: false, // Lock scroll during animation
force: false, // Force scroll even if already at target
})
}}
>
{children}
</a>
)
}
scrollTo accepts: number (px), string (CSS selector), HTMLElement, or 'top' / 'bottom'.
When using GSAP ScrollTrigger, Lenis must drive the RAF loop through GSAP's ticker — otherwise scroll positions desync.
import gsap from 'gsap'
import ScrollTrigger from 'gsap/ScrollTrigger'
import { ReactLenis } from 'lenis/react'
import { useEffect, useRef } from 'react'
import type { LenisRef } from 'lenis/react'
gsap.registerPlugin(ScrollTrigger)
export default function App({ children }: { children: React.ReactNode }) {
const lenisRef = useRef<LenisRef>(null)
useEffect(() => {
function update(time: number) {
lenisRef.current?.lenis?.raf(time * 1000) // GSAP time is in seconds
}
gsap.ticker.add(update)
gsap.ticker.lagSmoothing(0) // Prevents scroll jumps on tab focus
return () => gsap.ticker.remove(update)
}, [])
return (
<ReactLenis root options={{ autoRaf: false }} ref={lenisRef}>
{children}
</ReactLenis>
)
}
Critical: autoRaf: false — Lenis must NOT run its own RAF when GSAP is driving it.
import { ReactLenis } from 'lenis/react'
import type { LenisRef } from 'lenis/react'
import { cancelFrame, frame } from 'framer-motion'
import { useEffect, useRef } from 'react'
export default function App({ children }: { children: React.ReactNode }) {
const lenisRef = useRef<LenisRef>(null)
useEffect(() => {
function update({ timestamp }: { timestamp: number }) {
lenisRef.current?.lenis?.raf(timestamp)
}
frame.update(update, true)
return () => cancelFrame(update)
}, [])
return (
<ReactLenis root options={{ autoRaf: false }} ref={lenisRef}>
{children}
</ReactLenis>
)
}
For scrollable sections inside the page (not full-page scroll):
import { ReactLenis } from 'lenis/react'
import { useRef } from 'react'
function ScrollablePanel() {
const containerRef = useRef<HTMLDivElement>(null)
const contentRef = useRef<HTMLDivElement>(null)
return (
<ReactLenis
options={{
wrapper: containerRef, // The scrolling viewport
content: contentRef, // The inner content element
lerp: 0.1,
}}
>
<div ref={containerRef} style={{ height: '400px', overflow: 'hidden' }}>
<div ref={contentRef}>
{/* scrollable content */}
</div>
</div>
</ReactLenis>
)
}
Add 'use client' to any component using <ReactLenis> or useLenis:
// components/SmoothScrollProvider.tsx
'use client'
import { ReactLenis } from 'lenis/react'
export function SmoothScrollProvider({ children }: { children: React.ReactNode }) {
return (
<ReactLenis root options={{ lerp: 0.1 }}>
{children}
</ReactLenis>
)
}
// app/layout.tsx
import { SmoothScrollProvider } from '@/components/SmoothScrollProvider'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<SmoothScrollProvider>
{children}
</SmoothScrollProvider>
</body>
</html>
)
}
Some elements (modals, inner scrollable panels) should be excluded from Lenis:
<!-- HTML attribute approach -->
<div data-lenis-prevent>
<!-- This element scrolls natively -->
</div>
<!-- Horizontal-only prevention -->
<div data-lenis-prevent-wheel>...</div>
<div data-lenis-prevent-touch>...</div>
Or programmatically:
lenis?.stop() // Pause Lenis (e.g., modal open)
lenis?.start() // Resume Lenis (e.g., modal close)
When root is true, useLenis() works anywhere in the tree — even in components far from the provider. This is Lenis's key React advantage over manual prop-passing.
// In any deeply nested component:
import { useLenis } from 'lenis/react'
function DeepButton() {
const lenis = useLenis()
return <button onClick={() => lenis?.scrollTo('top')}>Top</button>
}
Always respect prefers-reduced-motion:
'use client'
import { ReactLenis } from 'lenis/react'
import { useReducedMotion } from 'framer-motion' // or a custom hook
export function SmoothScrollProvider({ children }: { children: React.ReactNode }) {
const reducedMotion = useReducedMotion()
if (reducedMotion) {
return <>{children}</> // Skip Lenis entirely
}
return (
<ReactLenis root options={{ lerp: 0.1 }}>
{children}
</ReactLenis>
)
}
Or with a plain media query hook if not using Framer Motion — see references/accessibility.md.
| Problem | Cause | Fix |
|---|---|---|
| Scroll jumps with GSAP ScrollTrigger | RAF loop conflict | Use autoRaf: false + gsap.ticker.add() |
| useLenis returns undefined | Component outside <ReactLenis> tree | Move provider higher, or use root prop |
| Lenis not working in Next.js | Missing 'use client' | Add directive to provider component |
| Modal/overlay blocks page scroll | Lenis still active | Call lenis.stop() on open, lenis.start() on close |
| iOS touch feels laggy | smoothTouch: true | Set smoothTouch: false (default) |
| scroll-behavior: smooth conflicts | CSS fighting Lenis | Add .lenis.lenis-smooth { scroll-behavior: auto !important; } |
| Anchor links don't work | Lenis intercepting | Lenis handles anchors — use scrollTo or data-lenis-prevent |
Create this once, use everywhere — the canonical pattern for React/Next.js projects:
// components/SmoothScrollProvider.tsx
'use client'
import { ReactLenis } from 'lenis/react'
import { useEffect, useRef } from 'react'
interface Props {
children: React.ReactNode
}
export function SmoothScrollProvider({ children }: Props) {
// Check for reduced motion preference
const prefersReducedMotion =
typeof window !== 'undefined'
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false
if (prefersReducedMotion) return <>{children}</>
return (
<ReactLenis
root
options={{
lerp: 0.1,
duration: 1.2,
smoothTouch: false,
syncTouch: false,
}}
>
{children}
</ReactLenis>
)
}
See references/recipes.md for more complete recipes including scroll progress bars, parallax, and infinite scroll.
Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer