Smooth Scrolling in React: Lenis, locomotive-scroll and Native CSS
Compare Lenis, locomotive-scroll, and native CSS scroll-behavior in React. Real code, real tradeoffs — pick the right tool before you ship your next project.
Why Native CSS scroll-behavior Isn't Enough
You've seen the one-liner. html { scroll-behavior: smooth; }. It works, kind of. In 2024 every major browser ships it, and for basic anchor links it's totally fine — but the moment you want easing curves, scroll velocity callbacks, or synchronized parallax effects, it falls apart immediately.
Native CSS smooth scroll has exactly one knob you can turn: on or off. There's no way to say "ease out with a 0.9 lerp factor" or "call this function every 16ms with the current scroll position". You can't hook into requestAnimationFrame. You can't pause it. You can't even reliably detect when it finishes in all browsers.
That said, don't dismiss it. For content-heavy marketing pages, documentation sites, or anything where you just need <a href="#section"> links to not jump, CSS smooth scroll is genuinely the right call. Zero bundle size. No JS. Works with screen readers out of the box. Know what you're building before reaching for a library.
Where it starts to hurt is animation-driven work — the kind of immersive UIs you'd pair with glassmorphism components or heavily layered visual effects. Those need precise scroll position values, and CSS alone can't give you that.
Lenis: The One You Probably Want
Lenis, built by the team at Studio Freight, is currently the dominant choice for smooth scroll in React projects. Version 1.1.x landed in early 2025 with a much cleaner React API, and the bundle size sits around 8kb gzipped. Not nothing, but not outrageous either.
The core idea is simple: Lenis intercepts native scroll events, stores its own virtual scroll position, then applies it to the document via transform or top on a wrapper element. The easing is configurable — the lerp option (linear interpolation factor between 0 and 1) is what most people tweak first. A value of 0.1 gives you that buttery slow catch-up feel; 0.9 is closer to native. Most portfolio sites land around 0.07 to 0.12.
Here's a minimal setup with the @studio-freight/lenis package in a Next.js App Router layout:
``tsx
// app/providers/lenis.tsx
'use client';
import { useEffect } from 'react';
import Lenis from '@studio-freight/lenis';
export function LenisProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
const lenis = new Lenis({
duration: 1.2,
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
orientation: 'vertical',
smoothWheel: true,
});
function raf(time: number) {
lenis.raf(time);
requestAnimationFrame(raf);
}
requestAnimationFrame(raf);
return () => lenis.destroy();
}, []);
return <>{children}</>;
}
`
Wrap your root layout with <LenisProvider> and you're done. The raf` loop is the part people most often get wrong — Lenis needs to be ticked manually on every frame.
Worth noting: if you're using GSAP's ScrollTrigger alongside Lenis, you need to proxy the scroll events so GSAP knows what position Lenis is reporting. The Lenis docs cover this with ScrollTrigger.normalizeScroll(). Skip that step and you'll get mismatched trigger points that are infuriating to debug.
One more thing — Lenis plays well with Framer Motion. You can feed Lenis's scroll value into a motion value and drive parallax effects off it. That's where it starts getting genuinely fun to use.
locomotive-scroll: More Power, More Complexity
Locomotive Scroll is older (version 4 shipped in 2021), heavier, and does more. It gives you a virtual scroll container with data-attribute-driven scroll effects, inertia, horizontal scroll sections, and element-level callbacks — all without writing a single line of JS for the individual effects.
Honestly, locomotive-scroll v4 has a reputation for being painful in React. You're reaching into the DOM imperatively, the instance management is tricky with React's render cycle, and the data-attribute approach (data-scroll, data-scroll-speed) is fundamentally opposed to how React thinks about UI. Version 5 improved things with better ESM support, but it still requires some gymnastics.
``tsx
import { useEffect, useRef } from 'react';
import LocomotiveScroll from 'locomotive-scroll';
import 'locomotive-scroll/dist/locomotive-scroll.css';
export function ScrollContainer({ children }: { children: React.ReactNode }) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!containerRef.current) return;
const scroll = new LocomotiveScroll({
el: containerRef.current,
smooth: true,
multiplier: 0.8,
lerp: 0.08,
});
return () => scroll.destroy();
}, []);
return (
<div ref={containerRef} data-scroll-container>
{children}
</div>
);
}
``
The data-scroll-container wrapper is required — locomotive-scroll measures and virtualizes the height of that element, not the document. This is actually the main footgun: your browser's native scroll position stays at 0 while locomotive tracks its own virtual position. Any third-party code that reads window.scrollY — analytics, sticky headers, scroll-to-top buttons — will be wrong until you hook them up to locomotive's scroll events.
In practice, I'd reach for locomotive-scroll when the designer hands you a spec with explicit parallax speed values per element and horizontal scroll sections mixed in. For everything else, Lenis is faster to ship and easier to maintain six months later.
Quick aside: both libraries have accessibility implications. Smooth scroll libraries that virtualize the document can confuse screen readers and break keyboard navigation if you're not careful. Always test with VoiceOver or NVDA. A 1200ms ease-in might feel premium to a mouse user and be genuinely disorienting to someone using a keyboard or assistive tech.
Integrating with GSAP ScrollTrigger and Framer Motion
This is where smooth scroll libraries get interesting — and where half the Stack Overflow questions come from. Both GSAP and Framer Motion rely on window.scrollY by default. Lenis and locomotive both override that reality with a virtual scroll position. You need to keep them in sync.
For Lenis + GSAP, the recommended pattern since GSAP 3.12 is:
``tsx
import { useEffect } from 'react';
import Lenis from '@studio-freight/lenis';
import gsap from 'gsap';
import ScrollTrigger from 'gsap/ScrollTrigger';
gsap.registerPlugin(ScrollTrigger);
export function useLenisGsap() {
useEffect(() => {
const lenis = new Lenis();
lenis.on('scroll', ScrollTrigger.update);
gsap.ticker.add((time) => {
lenis.raf(time * 1000);
});
gsap.ticker.lagSmoothing(0);
return () => {
lenis.destroy();
gsap.ticker.remove((time) => lenis.raf(time * 1000));
};
}, []);
}
`
The lagSmoothing(0)` call is important — without it GSAP will try to "catch up" during tab switches and you'll get snappy jumps in your animations.
For Framer Motion, you're usually better off using useScroll() from Framer Motion directly and passing the Lenis scroll value into a MotionValue. You'd expose lenis's scroll position via a React context, subscribe to it with lenis.on('scroll', ...), and update a useMotionValue accordingly. It sounds verbose but it's about 30 lines of code and it keeps Framer's spring physics intact.
Look, the real answer to "which scroll library integrates best with X" is almost always: profile your actual setup. Stacking Lenis + GSAP + Framer Motion on the same page with 40 animated elements will absolutely tank performance on mid-range Android devices. Pick the tool that owns scroll, and drive everything else off that single source of truth.
Performance Tradeoffs and When to Use Each
Every smooth scroll library adds a requestAnimationFrame loop that runs at 60fps (or 120fps on ProMotion displays). That's your baseline cost before you render anything. On a page with complex CSS like backdrop-filter — say, a glassmorphism generator tool or a heavily layered dashboard — that extra per-frame JS work adds up.
Native CSS scroll-behavior: smooth costs essentially zero at runtime. Lenis on a clean page adds maybe 1-2ms per frame on a modern laptop. Locomotive Scroll is heavier — it's also calculating element offsets constantly — and on pages with 100+ data-scroll elements it can climb to 5-8ms per frame. That's the difference between buttery and janky on a Pixel 6.
Here's the decision tree I actually use:
- Content site, anchor links only → CSS scroll-behavior: smooth, done
- Marketing page with scroll-linked animations → Lenis + GSAP ScrollTrigger
- Immersive portfolio with parallax per element → Consider locomotive-scroll v5, or Lenis + manual parallax with transform
- React app with complex UI state → Think hard before adding any smooth scroll library; framer-motion's useScroll + useTransform might be all you need
- Anything with a sticky nav, modals, or scroll locking → Test exhaustively; smooth scroll + overflow: hidden body locking is a known nightmare
Worth noting: if you're building components that need to coexist with smooth scroll — like a fixed sidebar that highlights the active section, or page-level css scroll animations — make sure you're consuming the library's scroll events, not window.scroll. This is the single most common bug I see in codebases that bolt smooth scroll on late.
The scroll-snap-type CSS property deserves a mention too. For section-by-section scrolling (full-page slides), native CSS scroll snap is remarkably good in 2026 and requires no library at all. It's not as cinematic as Lenis but it's zero-cost, accessible by default, and shippable in an afternoon.
A Production-Ready Lenis Hook for React
Here's the actual hook I'd drop into a project. It handles cleanup, exposes the lenis instance via context so child components can call lenis.scrollTo(), and plays nicely with Next.js App Router's client boundary requirements.
``tsx
// hooks/use-lenis.ts
import { createContext, useContext, useEffect, useRef } from 'react';
import Lenis from '@studio-freight/lenis';
type LenisContextValue = { lenis: Lenis | null };
export const LenisContext = createContext<LenisContextValue>({ lenis: null });
export function useLenis() {
return useContext(LenisContext);
}
// components/lenis-root.tsx
'use client';
import { useState, useEffect, useRef } from 'react';
import Lenis from '@studio-freight/lenis';
import { LenisContext } from '@/hooks/use-lenis';
export function LenisRoot({ children }: { children: React.ReactNode }) {
const [lenis, setLenis] = useState<Lenis | null>(null);
const rafId = useRef<number>(0);
useEffect(() => {
const instance = new Lenis({
duration: 1.1,
easing: (t) => (t === 1 ? 1 : 1 - Math.pow(2, -10 * t)),
touchMultiplier: 1.5,
infinite: false,
});
setLenis(instance);
const raf = (time: number) => {
instance.raf(time);
rafId.current = requestAnimationFrame(raf);
};
rafId.current = requestAnimationFrame(raf);
return () => {
cancelAnimationFrame(rafId.current);
instance.destroy();
setLenis(null);
};
}, []);
return (
<LenisContext.Provider value={{ lenis }}>
{children}
</LenisContext.Provider>
);
}
``
Then in any child component you can do:
``tsx
import { useLenis } from '@/hooks/use-lenis';
export function ScrollToTopButton() {
const { lenis } = useLenis();
return (
<button onClick={() => lenis?.scrollTo(0, { duration: 1.5 })}>
Back to top
</button>
);
}
``
The context pattern means you're not creating multiple Lenis instances — that's a real bug I've seen in codebases where the hook was called in both a layout and a page component simultaneously.
One thing that trips people up with Next.js specifically: 'use client' is required on the provider because Lenis uses window and requestAnimationFrame. If you accidentally put it in a Server Component tree without the directive you'll get a cryptic hydration error that's annoying to track down. Mark it early, save yourself the headache.
Smooth Scroll and Design Systems
If you're building a component library or a design system — the kind of thing you'd pair with style-forward components from Empire UI, neumorphism, or neobrutalism — think carefully about where smooth scroll lives in the architecture.
The scroll layer should be a single, application-level concern. Don't bake Lenis into individual components. A <ScrollReveal> component that instantiates its own Lenis is a disaster waiting to happen the moment two of them land on the same page. Instead, components should expose scroll-aware props or hooks that subscribe to a shared scroll context — the pattern from the section above.
Also consider reduced-motion preferences. The prefers-reduced-motion media query should disable smooth scrolling entirely for users who have it enabled. With Lenis you can check this on init:
``tsx
const prefersReducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)'
).matches;
const lenis = new Lenis({
duration: prefersReducedMotion ? 0 : 1.2,
lerp: prefersReducedMotion ? 1 : 0.1,
});
`
A lerp of 1` means no interpolation — scroll position jumps immediately to target, effectively disabling the smoothing while keeping the Lenis instance intact so your scroll callbacks still fire correctly.
Honestly, smooth scrolling is one of those features that users notice only when it's missing or broken. Done right, it's invisible. Done wrong — wrong lerp value, fighting with modal scroll locks, or broken keyboard navigation — it actively damages the experience. Invest the 30 minutes to do it properly and you won't think about it again.
FAQ
Lenis. The newer @studio-freight/react-lenis wrapper has first-class App Router support with a 'use client' boundary handled for you. Locomotive-scroll's DOM-centric API is a worse fit for React's component model, and you'll spend more time fighting the integration.
It can. Libraries that virtualize scroll position can confuse screen readers and break keyboard focus management. Always test with VoiceOver or NVDA, and respect prefers-reduced-motion by setting duration: 0 or lerp: 1 for users who've opted out of motion.
Not directly — useScroll reads window.scrollY and Lenis reports a virtual position. You need to expose Lenis's scroll value via a MotionValue and update it inside lenis.on('scroll', ...). About 15 lines of boilerplate, but it keeps both systems in sync.
Lenis adds roughly 1-2ms per frame on a clean page. Locomotive-scroll is heavier, especially with many data-scroll elements. On content-only sites, native CSS scroll-behavior: smooth costs essentially nothing and is often the right call.