Lenis Smooth Scroll Page — Virtual Scroll With Parallax

Lenis Smooth Scroll Page · Scroll · Plain HTML, CSS & JS · Live preview

Share & Support

What's included

Features

Exponential ease-out
Moves a fraction of remaining distance each frame, so it decelerates naturally.
You own the rAF loop
lenis.raf() is called manually so it shares one frame with other libraries.
Free scroll telemetry
scroll, velocity, and progress arrive already computed per frame.
Markup-driven parallax
Any element opts in with a data-parallax multiplier.
GPU-composited layers
translate3d keeps parallax off the layout path.
Cached per-frame work
Layers queried once, iterated with a plain for loop.
Eased anchors with offset
scrollTo(-70) stops targets hiding under the fixed nav.
Native scroll preserved
Keyboard, find-on-page, and screen readers keep working.

About this UI Snippet

Lenis Smooth Scroll Page — What Virtual Scrolling Really Does

Screenshot of the Lenis Smooth Scroll Page snippet rendered live

Smooth scrolling has a long history of being done badly. The versions that gave it a bad name hijacked the wheel, disabled the scrollbar, broke keyboard navigation, and made the browser's find-on-page jump to nowhere. Lenis is the modern answer, and the reason it works is a specific architectural choice: it keeps the native scroll position authoritative and only changes *how fast the page approaches it*.

The easing, and why it decelerates so naturally

The default easing looks cryptic and is worth understanding:

function (t) { return Math.min(1, 1.001 - Math.pow(2, -10 * t)); }

That is an exponential ease-out. On every frame the page moves a fraction of the *remaining* distance to its target, so movement is fast when far away and slows asymptotically as it arrives. This is why Lenis feels like inertia rather than an animation: a fixed-duration cubic-bezier always takes the same time regardless of distance, whereas an exponential approach naturally takes longer for a long throw and settles quickly for a short one. The 1.001 and Math.min exist to guarantee the function actually reaches exactly 1 rather than approaching it forever.

duration: 1.15 scales the whole feel. Below about 0.8 the effect is barely perceptible; above 1.5 the page starts feeling like it is fighting the user, which is the single most common way this is over-tuned.

You own the animation frame

Lenis deliberately does not start its own loop:

function raf(time) { lenis.raf(time); requestAnimationFrame(raf); } requestAnimationFrame(raf);

This looks like boilerplate but it is the most important design decision in the library. Because you call lenis.raf() yourself, Lenis can share a single animation frame with GSAP's ticker, a Three.js render loop, or a physics simulation. Libraries that run their own internal loop end up with two or three independent rAF callbacks per frame, which is exactly how scroll-linked animation drifts a frame out of sync with the scroll it is supposed to follow.

Parallax from the scroll event

lenis.on('scroll', ...) fires every frame with scroll, velocity, and progress already computed — no getBoundingClientRect() calls, no scroll listener of your own, no throttling to write.

The parallax is markup-driven. Any element can opt in by declaring a multiplier:

<h1 data-parallax="-0.18">

and the handler applies it:

el.style.transform = 'translate3d(0,' + (e.scroll * speed) + 'px,0)'

Negative values move the layer against the scroll so it appears further away; the magnitude is the depth. translate3d rather than translateY forces GPU compositing, so the transform never triggers layout.

Note the deliberate use of a plain for loop over the cached layers array. This runs on every single frame during scrolling, so re-querying the DOM or allocating a new array here is exactly the kind of per-frame waste that turns smooth scroll into jank.

Anchors, and the problem they create

Native anchor links are incompatible with virtual scrolling — the browser teleports to the target instantly, skipping everything Lenis is doing. So they are intercepted:

lenis.scrollTo(link.getAttribute('href'), { offset: -70, duration: 1.4 })

scrollTo() accepts a selector, an element, or a pixel value. The offset: -70 stops the target sliding under the fixed navigation bar — the detail every sticky-header site gets wrong on first attempt. The longer duration here is intentional: a deliberate jump reads better slightly slower than free scrolling.

The CSS that is genuinely required

html.lenis, html.lenis body { height: auto } and .lenis.lenis-smooth { scroll-behavior: auto !important } are not optional styling. Lenis adds those classes to the document itself. The second one matters most: if a stylesheet sets scroll-behavior: smooth, the browser's own smooth scrolling runs *simultaneously* with Lenis, and the two implementations fight over the same scroll position, producing stutter that looks like a performance problem but is a configuration one.

Accessibility, and the off switch

Smooth scrolling can trigger motion sickness, which is why the toggle calling lenis.stop() and lenis.start() exists. In production, that should be driven by prefers-reduced-motion automatically rather than a button — query the media list and never construct Lenis at all when reduction is requested. Because Lenis leaves the native scroll position intact, keyboard scrolling, find-on-page, and screen readers all continue to work normally either way, which is the substantive difference between it and the wheel-hijacking scripts it replaced.

Reusing it

Keep the required CSS, the rAF loop, and the scroll handler; everything else is content. Add data-parallax to anything you want to drift. If you also use GSAP ScrollTrigger, call ScrollTrigger.update from the same Lenis scroll event and drive lenis.raf from GSAP's ticker instead of your own. Compare with a scroll smoother parallax implementation, or scroll progress if all you need is the bar.

Build with AI

Build, Understand, Optimize, and Extend It With AI

The parts of this snippet worth understanding are the easing function and the loop ownership, neither of which is obvious from reading. Paste the HTML, CSS, and JS into an AI assistant like Claude and ask it to break down the default easing, 1.001 - Math.pow(2, -10 * t), and explain why an exponential approach feels like momentum where a fixed-duration cubic-bezier does not — and what the 1.001 and the Math.min are protecting against. Then ask why Lenis makes you write the requestAnimationFrame loop yourself instead of starting one internally, and how you would rewire it to be driven by GSAP ticker instead. Ask what specifically goes wrong if a stylesheet sets scroll-behavior: smooth while Lenis is running. For optimization, ask whether writing transform on several elements inside the per-frame scroll handler is a problem and how you would batch it if there were fifty parallax layers. To extend it: have it gate construction behind prefers-reduced-motion, integrate GSAP ScrollTrigger by calling ScrollTrigger.update from the Lenis scroll event, add a scroll-direction-aware hiding nav, or clamp parallax on mobile. Treat the code less like a finished artifact and more like a starting point for a conversation.

Prompt to recreate it

Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:

text
Build a smooth-scrolling page using the Lenis library (from a CDN, global Lenis) in plain HTML, CSS, and JavaScript.

Requirements:
- Instantiate Lenis with a duration around 1.15 and the exponential ease-out easing function t => Math.min(1, 1.001 - Math.pow(2, -10 * t)). Explain in a comment that this moves the page a fraction of the REMAINING distance each frame, which is why it decelerates like inertia rather than following a fixed curve.
- Drive it with your OWN requestAnimationFrame loop calling lenis.raf(time), and comment that Lenis deliberately does not start its own loop so it can share a single animation frame with GSAP, Three.js or a physics loop — libraries with internal loops end up a frame out of sync with the scroll they follow.
- Include the CSS Lenis requires and explain why it is not optional: html.lenis and html.lenis body { height: auto }, and .lenis.lenis-smooth { scroll-behavior: auto !important } — because if any stylesheet sets scroll-behavior: smooth the browser's native smooth scrolling runs simultaneously and fights Lenis over the same scroll position, producing stutter.
- Subscribe to lenis.on('scroll', ...) and use the supplied scroll, velocity and progress values (no getBoundingClientRect, no manual scroll listener) to drive three things: a fixed top progress bar width, a live readout panel showing scroll pixels, velocity to one decimal and progress percent, and markup-driven parallax.
- Implement parallax by letting any element opt in with a data-parallax attribute holding a multiplier, then setting translate3d(0, scroll * multiplier, 0) on it. Use negative multipliers so layers drift against the scroll and read as further away. Cache the queried layer list once outside the handler and iterate it with a plain for loop, since this runs every frame.
- Intercept navigation anchor clicks with preventDefault and use lenis.scrollTo(href, { offset: -70, duration: 1.4 }) instead — explain that native anchors teleport and bypass the virtual scroll entirely, and that the negative offset stops the target sliding under the fixed navigation bar.
- Add a toggle button calling lenis.stop() and lenis.start(), and note that in production this should be driven by prefers-reduced-motion, since smooth scrolling can cause motion sickness — and that because Lenis leaves the native scroll position authoritative, keyboard scrolling, find-on-page and screen readers keep working either way.
- Build a full multi-section dark page (tall hero plus three bands and a footer) so there is genuinely enough content to scroll through.

Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.

Step by step

How to Use

  1. 1
    Add the Lenis CDNInclude lenis from the CDN panel — global Lenis constructor.
  2. 2
    Paste HTML, CSS, and JSThe page scrolls with momentum and the metrics update live.
  3. 3
    Watch the readoutsScroll position, velocity, and progress come from the scroll event.
  4. 4
    Notice the parallaxHeadings with data-parallax drift against the page as you scroll.
  5. 5
    Use the nav linksAnchors are intercepted and eased with an offset for the fixed bar.
  6. 6
    Toggle it offlenis.stop() restores native scrolling instantly for comparison.

Real-world uses

Common Use Cases

Agency and portfolio sites
The weighted scroll feel behind most award-winning sites.
Long-form landing pages
Pair with scroll progress for orientation.
Scroll-driven storytelling
Feed the scroll event into pinned or scrubbed sections.
Parallax hero sections
A library-backed take on scroll parallax layers.
Product tours
Eased anchor jumps between sections instead of hard teleports.
Learning virtual scroll
A reference for rAF ownership and scroll-linked transforms.

Got questions?

Frequently Asked Questions

Its default easing is an exponential ease-out, so each frame the page moves a fraction of the remaining distance to the target. That means a long throw naturally takes longer and a short one settles quickly, whereas a fixed-duration bezier takes the same time regardless of distance. The result reads as inertia.

Because Lenis is designed to share one animation frame with whatever else you are running. Calling lenis.raf(time) from your own loop lets you drive it from GSAP ticker or a Three.js render loop, so scroll-linked animation stays in lockstep. Libraries with their own internal loop end up a frame out of sync with the scroll they follow.

Lenis adds .lenis and .lenis-smooth classes to the document element. The height: auto rules stop conflicting layout assumptions, and the scroll-behavior: auto override is critical — if any stylesheet sets scroll-behavior: smooth, the browser own smooth scrolling runs at the same time as Lenis and the two fight over scroll position, producing stutter that looks like a performance bug.

The scroll event supplies the current scroll offset every frame, and each opted-in element declares a multiplier via data-parallax. The handler sets translate3d(0, scroll * speed, 0). Negative multipliers move the layer against the scroll so it reads as further away, and translate3d forces GPU compositing so no layout is triggered.

A native anchor jump teleports the browser to the target instantly, bypassing the virtual scroll entirely. Calling lenis.scrollTo() with the href eases there instead. The offset of -70 accounts for the fixed navigation bar so the target heading does not end up hidden underneath it.

Create the Lenis instance in a mount effect at the app root, start the rAF loop there, and call lenis.destroy() plus cancelAnimationFrame in cleanup. For React specifically, the official lenis/react package provides a ReactLenis provider and a useLenis hook. Gate construction behind a prefers-reduced-motion check so reduced-motion users get native scrolling.