Skip to example

Lenis smooth scroll

Smooth scroll with Lenis, driven by Motion's frame loop, with a scroll-linked progress bar and parallax made with useScroll and useTransform.

React

Source code

"use client"

import { ReactLenis, type LenisRef } from "lenis/react"
import {
  cancelFrame,
  easeInOut,
  frame,
  motion,
  useScroll,
  useTransform,
} from "motion/react"
import { useEffect, useRef } from "react"

export default function LenisSmoothScroll() {
  const lenisRef = useRef<LenisRef>(null)

  useEffect(() => {
    // "setup" runs before Motion measures scroll, so useScroll
    // sees the scroll position Lenis writes in the same frame.
    const update = ({ timestamp }: { timestamp: number }) => {
      lenisRef.current?.lenis?.raf(timestamp)
    }

    frame.setup(update, true)

    return () => cancelFrame(update)
  }, [])

  const { scrollYProgress } = useScroll()
  const progress = useTransform(
    scrollYProgress,
    [0, 1],
    ["scaleX(0)", "scaleX(1)"]
  )

  const scrollToSection = (id: string) => {
    lenisRef.current?.lenis?.scrollTo(`#${id}`, {
      duration: 1.6,
      easing: easeInOut,
    })
  }

  return (
    <ReactLenis root options={{ autoRaf: false }} ref={lenisRef}>
      <nav className="lenis-nav">
        {SECTIONS.map(({ id }, i) => (
          <button key={id} onClick={() => scrollToSection(id)}>
            0{i + 1}
          </button>
        ))}
      </nav>
      {SECTIONS.map((section) => (
        <Section key={section.id} {...section} />
      ))}
      <motion.div className="lenis-progress" style={{ transform: progress }} />
      <Stylesheet />
    </ReactLenis>
  )
}

function Section({ id, image, title }: SectionData) {
  const ref = useRef(null)
  const { scrollYProgress } = useScroll({
    target: ref,
    offset: ["start end", "end start"],
  })
  const transform = useTransform(
    scrollYProgress,
    [0, 1],
    ["translateY(-300px)", "translateY(300px)"]
  )

  return (
    <section id={id} ref={ref} className="lenis-section">
      <div>
        <img src={image} alt="" />
      </div>
      <motion.h2 style={{ transform }}>{title}</motion.h2>
    </section>
  )
}

/**
 * ==============   Data   ================
 */

interface SectionData {
  id: string
  image: string
  title: string
}

const SECTIONS: SectionData[] = [1, 2, 3, 4, 5].map((n) => ({
  id: `lenis-${n}`,
  image: `/photos/cityscape/${n}.jpg`,
  title: `#00${n}`,
}))

/**
 * ==============   Styles   ================
 */

function Stylesheet() {
  return (
    <style>{`
      /* Recommended rules from lenis/dist/lenis.css */
      html.lenis,
      html.lenis body {
        height: auto;
      }

      .lenis:not(.lenis-autoToggle).lenis-stopped {
        overflow: clip;
      }

      .lenis.lenis-smooth iframe {
        pointer-events: none;
      }

      .lenis-section {
        height: 100vh;
        display: flex;
        justify-content: center;
        align-items: center;
        position: relative;
      }

      .lenis-section > div {
        width: 300px;
        height: 400px;
        margin: 20px;
        background: var(--white);
        overflow: hidden;
      }

      .lenis-section img {
        width: 300px;
        height: 400px;
        object-fit: cover;
      }

      .lenis-section h2 {
        color: var(--accent);
        margin: 0;
        font-family: "Geist Mono", monospace;
        font-size: 50px;
        font-weight: 700;
        letter-spacing: -3px;
        line-height: 1.2;
        position: absolute;
        top: calc(50% - 25px);
        left: calc(50% + 120px);
      }

      .lenis-nav {
        position: fixed;
        top: 20px;
        left: 50%;
        translate: -50% 0;
        display: flex;
        gap: 4px;
        padding: 4px;
        border-radius: 999px;
        background: var(--layer-transparent);
        border: 1px solid var(--border);
        backdrop-filter: blur(8px);
        z-index: 1;
      }

      .lenis-nav button {
        font-family: "Geist Mono", monospace;
        font-size: 14px;
        padding: 6px 12px;
        border-radius: 999px;
        color: var(--text);
      }

      .lenis-nav button:hover {
        background: var(--accent-transparent);
        color: var(--accent);
      }

      .lenis-progress {
        position: fixed;
        left: 0;
        right: 0;
        height: 5px;
        background: var(--accent);
        bottom: 50px;
        transform: scaleX(0);
        transform-origin: 0 50%;
      }
    `}</style>
  )
}