Repository: darkroomengineering/lenis Branch: main Commit: 8938b189273a Files: 100 Total size: 176.8 KB Directory structure: gitextract_ekruh0e_/ ├── .github/ │ ├── CODEOWNERS │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ └── bug_report.md │ └── dependabot.yml ├── .gitignore ├── .vscode/ │ ├── extensions.json │ └── settings.json ├── CONTRIBUTING.md ├── LICENSE ├── MANIFESTO.md ├── README.md ├── biome.json ├── package.json ├── packages/ │ ├── core/ │ │ ├── browser.ts │ │ ├── index.ts │ │ ├── lenis.css │ │ ├── package.json │ │ └── src/ │ │ ├── animate.ts │ │ ├── debounce.ts │ │ ├── dimensions.ts │ │ ├── emitter.ts │ │ ├── lenis.ts │ │ ├── maths.ts │ │ ├── types.ts │ │ └── virtual-scroll.ts │ ├── react/ │ │ ├── README.md │ │ ├── index.ts │ │ ├── package.json │ │ └── src/ │ │ ├── provider.tsx │ │ ├── store.ts │ │ ├── types.ts │ │ └── use-lenis.ts │ ├── snap/ │ │ ├── README.md │ │ ├── browser.ts │ │ ├── index.ts │ │ ├── package.json │ │ └── src/ │ │ ├── debounce.ts │ │ ├── element.ts │ │ ├── snap.ts │ │ ├── types.ts │ │ └── uid.ts │ └── vue/ │ ├── README.md │ ├── index.ts │ ├── nuxt/ │ │ ├── module.ts │ │ ├── runtime/ │ │ │ └── lenis.ts │ │ ├── tsconfig.json │ │ └── types/ │ │ ├── app.d.ts │ │ └── imports.d.ts │ ├── package.json │ └── src/ │ ├── provider.ts │ ├── store.ts │ └── use-lenis.ts ├── playground/ │ ├── .gitignore │ ├── astro.config.mjs │ ├── core/ │ │ ├── browser.js │ │ ├── static.html │ │ ├── style.css │ │ └── test.ts │ ├── horizontal/ │ │ ├── browser.js │ │ ├── static.html │ │ ├── style.css │ │ └── test.ts │ ├── infinite/ │ │ ├── browser.js │ │ ├── static.html │ │ ├── style.css │ │ └── test.ts │ ├── nuxt/ │ │ ├── .gitignore │ │ ├── README.md │ │ ├── app.vue │ │ ├── components/ │ │ │ └── inner.vue │ │ ├── nuxt.config.ts │ │ ├── package.json │ │ ├── pages/ │ │ │ ├── about.vue │ │ │ └── index.vue │ │ ├── plugins/ │ │ │ └── lenis.ts │ │ ├── public/ │ │ │ └── robots.txt │ │ ├── server/ │ │ │ └── tsconfig.json │ │ └── tsconfig.json │ ├── package.json │ ├── react/ │ │ ├── app.tsx │ │ └── style.css │ ├── snap/ │ │ ├── style.css │ │ └── test.ts │ ├── tsconfig.json │ ├── vue/ │ │ ├── App.vue │ │ ├── Child.vue │ │ ├── InnerChild.vue │ │ ├── setup.ts │ │ └── style.css │ └── www/ │ ├── layouts/ │ │ └── Layout.astro │ └── pages/ │ ├── core.astro │ ├── horizontal.astro │ ├── index.astro │ ├── infinite.astro │ ├── react.astro │ ├── snap.astro │ └── vue.astro ├── scripts/ │ └── update-readme.js ├── tsconfig.json └── tsdown.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/CODEOWNERS ================================================ * @darkroomengineering/devs ================================================ FILE: .github/FUNDING.yml ================================================ github: [darkroomengineering] polar: darkroomengineering ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ ## Before to submit your issue Read the [Troubleshooting](https://github.com/darkroomengineering/lenis#troubleshooting) section. ## Describe the bug A clear and concise description of what the bug is. ## To Reproduce Try to reproduce your issue by forking this [codepen](https://codepen.io/ClementRoche/pen/VwxgZEP). If you can't reproduce it, it means there is something wrong on your initial environment, please read the documentation again. If your issue doesn't include any reproduction link, it will take more time to be treaten. ================================================ FILE: .github/dependabot.yml ================================================ # To get started with Dependabot version updates, you'll need to specify which # package ecosystems to update and where the package manifests are located. # Please see the documentation for all configuration options: # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates version: 2 updates: - package-ecosystem: "github-actions" # See documentation for possible values directory: "/" # Location of package manifests schedule: interval: "daily" ================================================ FILE: .gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies node_modules /.pnp .pnp.js # testing /coverage # next.js .next/ /out/ # production /build /docs/dist # misc .DS_Store *.pem # debug npm-debug.log* yarn-debug.log* yarn-error.log* # local env files .env .env.local .env.development.local .env.test.local .env.production.local # vercel .vercel .eslintcache .npmrc packages/core/dist/ packages/react/dist/ packages/snap/dist/ packages/vue/dist/ dist-new/ dist/ .tldr/ .tldrignore ================================================ FILE: .vscode/extensions.json ================================================ { "recommendations": [ // Formatting & Linting "biomejs.biome", // CSS & Styling "bradlc.vscode-tailwindcss", "csstools.postcss", // GraphQL (Shopify, etc.) "graphql.vscode-graphql-syntax", // Sanity CMS (GROQ syntax + validation) "sanity-io.vscode-sanity", // DX Enhancements "yoavbls.pretty-ts-errors", "waderyan.gitblame" ] } ================================================ FILE: .vscode/settings.json ================================================ { // Formatting "editor.formatOnSave": true, "editor.defaultFormatter": "biomejs.biome", "editor.codeActionsOnSave": { "source.fixAll.biome": "always", "source.organizeImports.biome": "always" }, // TypeScript "typescript.suggest.autoImports": true, "typescript.preferences.importModuleSpecifier": "non-relative", "typescript.tsdk": "node_modules/typescript/lib", // "typescript.experimental.useTsgo": true, // Disabled: tsgo doesn't support Next.js plugin "javascript.suggest.autoImports": true, // Editor labels (fixed for .tsx files) "workbench.editor.customLabels.patterns": { "**/app/**/page.tsx": "${dirname(1)}/${dirname} ", "**/app/**/layout.tsx": "${dirname(1)}/${dirname} ", "**/app/api/**/route.ts": "${dirname(1)}/${dirname} ", "**/components/**/index.tsx": "${dirname} " }, // File associations "files.associations": { "*.json": "jsonc" }, // CSS (Tailwind v4) "css.lint.validProperties": ["user-drag"], "css.lint.unknownAtRules": "ignore", "tailwindCSS.experimental.configFile": "lib/styles/css/tailwind.css", "tailwindCSS.includeLanguages": { "typescriptreact": "html" }, // Search exclusions (performance) "search.exclude": { "**/node_modules": true, "**/.next": true, "**/bun.lock": true, "**/.vercel": true, "**/lib/integrations/sanity/sanity.types.ts": true }, // Language-specific formatters "[css]": { "editor.defaultFormatter": "biomejs.biome" }, "[javascript]": { "editor.defaultFormatter": "biomejs.biome" }, "[typescript]": { "editor.defaultFormatter": "biomejs.biome" }, "[javascriptreact]": { "editor.defaultFormatter": "biomejs.biome" }, "[typescriptreact]": { "editor.defaultFormatter": "biomejs.biome" }, "[json]": { "editor.defaultFormatter": "biomejs.biome" }, "[jsonc]": { "editor.defaultFormatter": "biomejs.biome" }, // General "files.eol": "\n", "workbench.editorAssociations": { "*.svg": "default" } } ================================================ FILE: CONTRIBUTING.md ================================================ # Lenis Contributing Guide Yooo! We're really excited that you're interested in contributing to Lenis! Before submitting your contribution, please read through the following guide. ## Repo Setup To develop locally, fork the Lenis repository and clone it in your local machine. The Lenis repo is a monorepo using pnpm workspaces. The package manager used to install and link dependencies must be [pnpm](https://pnpm.io/). To start developing Lenis, run the following commands in the root of the repository: 1. Run `pnpm i` in Lenis's root folder. 2. Run `pnpm dev` in Lenis's root folder. 3. Open http://localhost:4321 in your browser, which has a playground for Lenis. The dev server will automatically rebuild Lenis whenever you change its code no matter what package you are working on. At the same time the playground will automatically reload when you change the code of any package. ## Pull Request Guidelines - Checkout a topic branch from a base branch (e.g. `main`), and merge back against that branch. - If adding a new feature: - Provide a convincing reason to add this feature. Ideally, you should open a suggestion issue first, and have it approved before working on it. - If fixing a bug: - Provide a detailed description of the bug in the PR. Codepen demo preferred. - Make sure to enable prettier in your editor to format the code. ================================================ FILE: LICENSE ================================================ The MIT License Copyright (c) 2024 darkroom.engineering Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: MANIFESTO.md ================================================ # For the Nerds 🧠 Alright, let's get nerdy for a minute because you probably installed Lenis for smooth scrolling and don’t even know the whole real story behind it. Originally, Lenis wasn’t built just to make your site scroll like butter (even though that’s a pretty nice side effect). No, the real mission was to tackle a major pain point in web development that most folks don't realize exists—synchronizing WebGL and the DOM while scrolling. ## The Real Problem 🤔 You see, WebGL and the DOM don’t play nicely together when you’re scrolling. With native scrolling, trying to keep WebGL animations in sync with DOM elements is like trying to teach a cat to fetch—it just doesn’t want to cooperate. There’s a constant fight over control, which means you end up with janky animations, weird timing issues, and an overall frustrating experience for developers and users alike. Lenis came in as the referee, letting us manage the scroll position smoothly and precisely, so WebGL and the DOM can finally share the spotlight. ## The Happy Mistake 🎉 But here’s the kicker—when we made Lenis to solve that problem, something interesting happened. Thanks to its ability to interpolate (or “lerp,” for the cool kids) the scroll position, it also created a super-smooth scrolling experience. And as it turns out, everyone just loves smooth scrolling. So much so that this “happy little accident” quickly overshadowed the original problem Lenis was built to solve. People started adopting it just for the silky smooth scrolling, completely unaware that Lenis was originally the secret weapon for complex WebGL-DOM synchronization. ## So… What’s the Point? If you’re here thinking, “I just wanted my site to scroll like butter,” don’t worry—you’re not alone! Smooth scrolling is awesome, and Lenis does it really well. But for those of you who really want to know, Lenis is more than just a pretty face. It’s here to handle the hard stuff under the hood and to give you the control you need to pull off those super-synced, glitch-free animations. In short: Lenis is the smooth scroll library that became famous by accident. So, next time you add it to your project, just know that it's not just a scrolling effect—it's a powerhouse tool for handling the impossible. ================================================ FILE: README.md ================================================ [![LENIS](https://assets.darkroom.engineering/lenis/banner.gif)](https://github.com/darkroomengineering/lenis) [![npm](https://img.shields.io/npm/v/lenis?colorA=E30613&colorB=000000 )](https://www.npmjs.com/package/lenis) [![downloads](https://img.shields.io/npm/dm/lenis?colorA=E30613&colorB=000000 )](https://www.npmjs.com/package/lenis) [![size](https://img.shields.io/bundlephobia/minzip/lenis?label=size&colorA=E30613&colorB=000000)](https://bundlephobia.com/package/lenis) ## Introduction Lenis ("smooth" in latin) is a lightweight, robust, and performant smooth scroll library. It's designed by [@darkroom.engineering](https://twitter.com/darkroomdevs) to be simple to use and easy to integrate into your projects. It's built with performance in mind and is optimized for modern browsers. It's perfect for creating smooth scrolling experiences on your website such as WebGL scroll syncing, parallax effects, and much more, see [Demo](https://lenis.darkroom.engineering/) and [Showcase](https://www.lenis.dev/showcase). Read our [Manifesto](https://github.com/darkroomengineering/lenis/blob/main/MANIFESTO.md) to learn more about the inspiration behind Lenis.
- [Sponsors](#sponsors) - [Packages](#packages) - [Showcase](https://www.lenis.dev/showcase) - [Installation](#installation) - [Setup](#setup) - [Settings](#settings) - [Properties](#properties) - [Methods](#methods) - [Events](#events) - [Considerations](#considerations) - [Limitations](#limitations) - [Troubleshooting](#troubleshooting) - [Tutorials](#tutorials) - [Plugins](#plugins) - [License](#license)
## Sponsors If you’ve used Lenis and it made your site feel just a little more alive, consider [sponsoring](https://github.com/sponsors/darkroomengineering). Your support helps us smooth out the internet one library at a time—and lets us keep building tools that care about the details most folks overlook.
[![Jesse Winton](https://img.logo.dev/cosmos.so?size=64&token=pk_E-KcYZmdT--jxwGY3dAs1Q&fallback=404)](mailto:jesse@cosmos.so) [![smsunarto](https://github.com/smsunarto.png?size=64)](https://github.com/smsunarto) [![bizarro](https://github.com/bizarro.png?size=64)](https://github.com/bizarro) [![itsoffbrand](https://github.com/itsoffbrand.png?size=64)](https://github.com/itsoffbrand) [![arkconclave](https://github.com/arkconclave.png?size=64)](https://github.com/arkconclave) [![Tamas Bodo](https://img.logo.dev/framerpod.com?size=64&token=pk_E-KcYZmdT--jxwGY3dAs1Q&fallback=404)](mailto:hello@framerpod.com) [![glauber-sampaio](https://github.com/glauber-sampaio.png?size=64)](https://github.com/glauber-sampaio) [![cachet-studio](https://github.com/cachet-studio.png?size=64)](https://github.com/cachet-studio) [![OHO-Design](https://github.com/OHO-Design.png?size=64)](https://github.com/OHO-Design) [![joevingracien](https://github.com/joevingracien.png?size=64)](https://github.com/joevingracien) [![Lazar Filipovic](https://ui-avatars.com/api/?name=Lazar+Filipovic&size=64)](mailto:webdesignbylazar@gmail.com)
Vercel OSS Program
## Packages - [lenis](https://github.com/darkroomengineering/lenis/blob/main/README.md) - [lenis/react](https://github.com/darkroomengineering/lenis/blob/main/packages/react/README.md) - [lenis/vue](https://github.com/darkroomengineering/lenis/tree/main/packages/vue/README.md) - [lenis/framer](https://lenis.framer.website/) - [lenis/snap](https://github.com/darkroomengineering/lenis/tree/main/packages/snap/README.md)
## Installation Using a package manager: ```bash npm i lenis # or yarn add lenis # or pnpm add lenis ``` ```js import Lenis from 'lenis' ```
Using scripts: ```html ```
## Setup ### Basic: ```js // Initialize Lenis const lenis = new Lenis({ autoRaf: true, }); // Listen for the scroll event and log the event data lenis.on('scroll', (e) => { console.log(e); }); ``` ### Custom raf loop: ```js // Initialize Lenis const lenis = new Lenis(); // Use requestAnimationFrame to continuously update the scroll function raf(time) { lenis.raf(time); requestAnimationFrame(raf); } requestAnimationFrame(raf); ``` ### Recommended CSS: **Import stylesheet:** ```js import 'lenis/dist/lenis.css' ``` **Or link the CSS file:** ```html ``` **Or add it manually:** [See lenis.css stylesheet](./packages/core/lenis.css) ### GSAP ScrollTrigger: ```js // Initialize a new Lenis instance for smooth scrolling const lenis = new Lenis(); // Synchronize Lenis scrolling with GSAP's ScrollTrigger plugin lenis.on('scroll', ScrollTrigger.update); // Add Lenis's requestAnimationFrame (raf) method to GSAP's ticker // This ensures Lenis's smooth scroll animation updates on each GSAP tick gsap.ticker.add((time) => { lenis.raf(time * 1000); // Convert time from seconds to milliseconds }); // Disable lag smoothing in GSAP to prevent any delay in scroll animations gsap.ticker.lagSmoothing(0); ``` ### React: [See documentation for lenis/react](https://github.com/darkroomengineering/lenis/blob/main/packages/react/README.md).
## Settings | Option | Type | Default | Description | |-------------------------|----------------------------|----------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `wrapper` | `HTMLElement, Window` | `window` | The element that will be used as the scroll container. | | `content` | `HTMLElement` | `document.documentElement` | The element that contains the content that will be scrolled, usually `wrapper`'s direct child. | | `eventsTarget` | `HTMLElement, Window` | `wrapper` | The element that will listen to `wheel` and `touch` events. | | `smoothWheel` | `boolean` | `true` | Smooth the scroll initiated by `wheel` events. | | `lerp` | `number` | `0.1` | Linear interpolation (lerp) intensity (between 0 and 1). | | `duration` | `number` | `1.2` | The duration of scroll animation (in seconds). Useless if lerp defined. | | `easing` | `function` | `(t) => Math.min(1, 1.001 - Math.pow(2, -10 * t))` | The easing function to use for the scroll animation, our default is custom but you can pick one from [Easings.net](https://easings.net/en). Useless if lerp defined. | | `orientation` | `string` | `vertical` | The orientation of the scrolling. Can be `vertical` or `horizontal`. | | `gestureOrientation` | `string` | `vertical` | The orientation of the gestures. Can be `vertical`, `horizontal` or `both`. | | `syncTouch` | `boolean` | `false` | Mimic touch device scroll while allowing scroll sync (can be unstable on iOS<16). | | `syncTouchLerp` | `number` | `0.075` | Lerp applied during `syncTouch` inertia. | | `touchInertiaExponent` | `number` | `1.7` | Manage the strength of syncTouch inertia. | | `wheelMultiplier` | `number` | `1` | The multiplier to use for mouse wheel events. | | `touchMultiplier` | `number` | `1` | The multiplier to use for touch events. | | `infinite` | `boolean` | `false` | Enable infinite scrolling! `syncTouch: true` is required on touch devices ([See example](https://codepen.io/ClementRoche/pen/OJqBLod)). | | `autoResize` | `boolean` | `true` | Resize instance automatically based on `ResizeObserver`. If `false` you must resize manually using `.resize()`. | | `prevent` | `function` | `undefined` | Manually prevent scroll to be smoothed based on elements traversed by events. If `true` is returned, it will prevent the scroll to be smoothed. Example: `(node) => node.classList.contains('cookie-modal')`. | | `virtualScroll` | `function` | `undefined` | Manually modify the events before they get consumed. If `false` is returned, the scroll will not be smoothed. Examples: `(e) => { e.deltaY /= 2 }` (to slow down vertical scroll) or `({ event }) => !event.shiftKey` (to prevent scroll to be smoothed if shift key is pressed). | | `overscroll` | `boolean` | `true` | Similar to CSS overscroll-behavior (https://developer.mozilla.org/en-US/docs/Web/CSS/overscroll-behavior). | | `autoRaf` | `boolean` | `false` | Whether or not to automatically run `requestAnimationFrame` loop. | | `anchors` | `boolean, ScrollToOptions` | `false` | Scroll to anchor links when clicked. If `true` is passed, it will enable anchor links with default options. If `ScrollToOptions` is passed, it will enable anchor links with the given options. | | `autoToggle` | `boolean` | `false` | Automatically start or stop the lenis instance based on the wrapper's overflow property, ⚠️ this requires Lenis recommended CSS. Safari > 17.3, Chrome > 116 and Firefox > 128 ([https://caniuse.com/?search=transition-behavior](https://caniuse.com/?search=transition-behavior)). | | `allowNestedScroll` | `boolean` | `false` | Automatically allow nested scrollable elements to scroll natively. This is the simplest way to handle nested scroll. ⚠️ Can create performance issues since it checks the DOM tree on every scroll event. If that's a concern, use `data-lenis-prevent` attributes instead. | | `naiveDimensions` | `boolean` | `false` | If `true`, Lenis will use naive dimensions calculation. ⚠️ Be careful, this has a performance impact. | | `stopInertiaOnNavigate` | `boolean` | `false` | If `true`, Lenis will stop inertia when an internal link is clicked. |
## Properties | Property | Type | Description | |-------------------------|-------------------|----------------------------------------------------------------------------| | `animatedScroll` | `number` | Current scroll value | | `dimensions` | `object` | Dimensions instance | | `direction` | `number` | `1`: scrolling up, `-1`: scrolling down | `options` | `object` | Instance options | | `targetScroll` | `number` | Target scroll value | | `time` | `number` | Time elapsed since instance creation | | `actualScroll` | `number` | Current scroll value registered by the browser | | `lastVelocity` | `number` | last scroll velocity | | `velocity` | `number` | Current scroll velocity | | `isHorizontal` (getter) | `boolean` | Whether or not the instance is horizontal | | `isScrolling` (getter) | `boolean, string` | Whether or not the scroll is being animated, `smooth`, `native` or `false` | | `isStopped` (getter) | `boolean` | Whether or not the user should be able to scroll | | `limit` (getter) | `number` | Maximum scroll value | | `progress` (getter) | `number` | Scroll progress from `0` to `1` | | `rootElement` (getter) | `HTMLElement` | Element on which Lenis is instanced | | `scroll` (getter) | `number` | Current scroll value (handles infinite scroll if activated) | | `className` (getter) | `string` | `rootElement` className |
## Methods | Method | Description | Arguments | |-----------------------------|---------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `raf(time)` | Must be called every frame for internal usage. | `time`: in ms | | `scrollTo(target, options)` | Scroll to target. | `target`: goal to reach
  • `number`: value to scroll in pixels
  • `string`: CSS selector or keyword (`top`, `left`, `start`, `bottom`, `right`, `end`)
  • `HTMLElement`: DOM element
`options`
  • `offset`(`number`): equivalent to [`scroll-padding-top`](https://developer.mozilla.org/en-US/docs/Web/CSS/scroll-padding-top)
  • `lerp`(`number`): animation lerp intensity
  • `duration`(`number`): animation duration (in seconds)
  • `easing`(`function`): animation easing
  • `immediate`(`boolean`): ignore duration, easing and lerp
  • `lock`(`boolean`): whether or not to prevent the user from scrolling until the target is reached
  • `force`(`boolean`): reach target even if instance is stopped
  • `onComplete`(`function`): called when the target is reached
  • `userData`(`object`): this object will be forwarded through `scroll` events
| | `on(id, function)` | `id` can be any of the following [instance events](#instance-events) to listen. | | | `stop()` | Pauses the scroll | | | `start()` | Resumes the scroll | | | `resize()` | Compute internal sizes, it has to be used if `autoResize` option is `false`. | | | `destroy()` | Destroys the instance and removes all events. | | ## Events | Event | Callback Arguments | |------------------|---------------------------| | `scroll` | Lenis instance | | `virtual-scroll` | `{deltaX, deltaY, event}` |
## Considerations ### Nested scroll The simplest and most reliable way to handle nested scrollable elements is to use the `allowNestedScroll` option: ```js const lenis = new Lenis({ allowNestedScroll: true, }) ``` This automatically detects nested scrollable elements and lets them scroll natively. However, this can create performance issues since Lenis needs to check the DOM tree on every scroll event. If you experience performance problems, use `data-lenis-prevent` instead. #### Using HTML attributes ```html
scrollable content
``` [See example](https://codepen.io/ClementRoche/pen/PoLdjpw) | Attribute | Description | |---------------------------------|--------------------------------------| | `data-lenis-prevent` | Prevent all smooth scroll events | | `data-lenis-prevent-wheel` | Prevent wheel events only | | `data-lenis-prevent-touch` | Prevent touch events only | | `data-lenis-prevent-vertical` | Prevent vertical scroll events only | | `data-lenis-prevent-horizontal` | Prevent horizontal scroll events only| #### Using Javascript ```html ``` ```js const lenis = new Lenis({ prevent: (node) => node.id === 'modal', }) ``` [See example](https://codepen.io/ClementRoche/pen/emONGYN) ### Anchor links By default, Lenis will prevent anchor links from working while scrolling. To enable them, you must set `anchors: true`. ```js new Lenis({ anchors: true }) ``` You can also use `scrollTo` options: ```js new Lenis({ anchors: { offset: 100, onComplete: ()=>{ console.log('scrolled to anchor') } } }) ```
## Limitations - no support for CSS scroll-snap, you must use ([lenis/snap](https://github.com/darkroomengineering/lenis/tree/main/packages/snap/README.md)) - capped to 60fps on Safari ([source](https://bugs.webkit.org/show_bug.cgi?id=173434)) and 30fps on low power mode - smooth scroll will stop working over iframe since they don't forward wheel events - position fixed seems to lag on MacOS Safari pre-M1 ([source](https://github.com/darkroomengineering/lenis/issues/103)) - touch events may behave unexpectedly when `syncTouch` is enabled on iOS < 16 - nested scroll containers require proper configuration to work correctly
## Troubleshooting - Make sure you use the latest version of [Lenis](https://www.npmjs.com/package/lenis?activeTab=versions) - Include the recommended CSS - If using GSAP ScrollTrigger, ensure proper integration (see [GSAP ScrollTrigger setup](#setup) section) - Test without Lenis to ensure your element/page is scrollable - Be sure to use `autoRaf: true` or manually call `lenis.raf(time)` in your animation loop
## Tutorials - [Scroll Animation Ideas for Image Grids](https://tympanus.net/Development/ScrollAnimationsGrid/) by [Codrops](https://tympanus.net/codrops) - [How to Animate SVG Shapes on Scroll](https://tympanus.net/codrops/2022/06/08/how-to-animate-svg-shapes-on-scroll) by [Codrops](https://tympanus.net/codrops) - [The BEST smooth scrolling library for your Webflow website! (Lenis)](https://www.youtube.com/watch?v=VtCqTLRRMII) by [Diego Toda de Oliveira](https://www.diegoliv.works/) - [Easy smooth scroll in @Webflow with Lenis + GSAP ScrollTrigger tutorial](https://www.youtube.com/watch?v=gRKuzQTXq74) by [También Studio](https://www.tambien.studio/)
## Plugins - [r3f-scroll-rig](https://github.com/14islands/r3f-scroll-rig) by [14islands](https://14islands.com/) - [locomotive-scroll](https://github.com/locomotivemtl/locomotive-scroll) by [Locomotive](https://locomotive.ca/)
## License MIT © [darkroom.engineering](https://github.com/darkroomengineering) ================================================ FILE: biome.json ================================================ { "$schema": "node_modules/@biomejs/biome/configuration_schema.json", "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, "files": { "ignoreUnknown": true, "includes": [ "**", "!node_modules", "!**/.next", "!**/dist", "!**/public", "!.github", "!.vercel", "!pnpm-lock.yaml", "!bun.lock", "!**/*.md", "!**/*.mdx", "!**/tailwind.css", "!**/root.css", "!**/*.grit" ] }, "formatter": { "enabled": true, "indentStyle": "space", "indentWidth": 2, "lineEnding": "lf", "lineWidth": 80 }, "assist": { "actions": { "source": { "organizeImports": "on" } } }, "linter": { "enabled": true, "domains": { "next": "recommended", "react": "recommended", "project": "recommended" }, "rules": { "correctness": { "noUnusedImports": "error", "noUnusedVariables": "error", "noUnusedFunctionParameters": "warn", "useExhaustiveDependencies": "warn", "noUnknownMediaFeatureName": "off", "noInvalidUseBeforeDeclaration": "error" }, "style": { "noNonNullAssertion": "off", "noUnusedTemplateLiteral": "off", "noParameterAssign": "error", "useAsConstAssertion": "error", "useDefaultParameterLast": "error", "useEnumInitializers": "error", "useSelfClosingElements": "error", "useSingleVarDeclarator": "error", "useNumberNamespace": "error", "noInferrableTypes": "error", "noUselessElse": "error", "useConsistentArrayType": "error", "useForOf": "warn", "useShorthandAssign": "error", "useTemplate": "warn", "useCollapsedElseIf": "warn", "useExponentiationOperator": "error", "useConsistentBuiltinInstantiation": "error", "useFilenamingConvention": { "level": "warn", "options": { "filenameCases": ["kebab-case", "camelCase"], "strictCase": false } }, "noNestedTernary": "error" }, "suspicious": { "noExplicitAny": "error", "noEmptyBlockStatements": "warn", "noDoubleEquals": "error", "noDebugger": "warn", "noGlobalIsFinite": "error", "noGlobalIsNan": "error", "noMisleadingCharacterClass": "error", "noPrototypeBuiltins": "warn", "noSelfCompare": "error", "noSparseArray": "error", "useAwait": "off" }, "complexity": { "noForEach": "off", "useSimplifiedLogicExpression": "warn", "useFlatMap": "warn" }, "security": { "noGlobalEval": "error", "noDangerouslySetInnerHtml": "warn", "noDangerouslySetInnerHtmlWithChildren": "error" }, "a11y": { "useKeyWithClickEvents": "warn", "useValidAnchor": "warn", "useAltText": "error", "useButtonType": "error", "useValidAriaProps": "error", "useValidAriaRole": "error", "useValidAriaValues": "error", "noAriaUnsupportedElements": "error", "noAutofocus": "warn", "noDistractingElements": "error", "noRedundantAlt": "error", "useSemanticElements": "warn" }, "performance": { "noImgElement": "error" }, "nursery": { "useSortedClasses": { "level": "error", "fix": "safe", "options": { "attributes": ["class", "className"], "functions": ["cn", "clsx"] } } } } }, "javascript": { "formatter": { "enabled": true, "quoteStyle": "single", "semicolons": "asNeeded", "trailingCommas": "es5" } }, "json": { "parser": { "allowComments": true } }, "css": { "linter": { "enabled": true }, "formatter": { "enabled": true }, "parser": { "cssModules": true } }, "overrides": [ { "includes": ["**/*.css"], "linter": { "rules": { "correctness": { "noUnknownFunction": "off" } } } }, { "includes": ["**/*.tsx", "**/*.jsx"], "linter": { "rules": { "correctness": { "useJsxKeyInIterable": "error" }, "a11y": { "useValidAnchor": "error", "useKeyWithClickEvents": "error", "useKeyWithMouseEvents": "error" } } } }, { "includes": ["**/*.ts", "**/*.tsx"], "linter": { "rules": { "style": { "useImportType": "error", "useExportType": "error", "useConsistentArrayType": "error" }, "correctness": { "noUndeclaredVariables": "off" } } } }, { "includes": [ "app/**/*.tsx", "app/**/*.ts", "app/**/*.jsx", "app/**/*.js" ], "linter": { "rules": { "style": { "noDefaultExport": "off" }, "suspicious": { "useAwait": "off" } } } }, { "includes": ["**/*.module.css"], "linter": { "rules": { "correctness": { "noUnknownProperty": "off" }, "style": { "noDescendingSpecificity": "off" } } } }, { "includes": ["lib/styles/css/root.css"], "linter": { "rules": { "suspicious": { "noDuplicateCustomProperties": "off" } } } }, { "includes": ["**/*.vue"], "linter": { "rules": { "correctness": { "useHookAtTopLevel": "off" }, "style": { "useFilenamingConvention": "off" } } } }, { "includes": ["**/*.astro"], "linter": { "rules": { "correctness": { "noUnusedImports": "off", "noUnusedVariables": "off" }, "style": { "useFilenamingConvention": "off" } } } } ] } ================================================ FILE: package.json ================================================ { "name": "lenis", "version": "1.3.19", "description": "How smooth scroll should be", "type": "module", "sideEffects": false, "author": "darkroom.engineering", "license": "MIT", "repository": { "type": "git", "url": "git+https://github.com/darkroomengineering/lenis.git" }, "bugs": { "url": "https://github.com/darkroomengineering/lenis/issues" }, "homepage": "https://github.com/darkroomengineering/lenis", "funding": { "type": "github", "url": "https://github.com/sponsors/darkroomengineering" }, "keywords": [ "scroll", "smooth", "lenis", "react", "vue" ], "workspaces": [ "packages/*", "playground", "playground/*" ], "scripts": { "build": "tsdown", "dev": "bun run --parallel dev:build dev:playground", "dev:build": "tsdown --watch", "dev:playground": "bun --filter playground dev", "dev:nuxt": "bun --filter playground-nuxt dev", "readme": "node ./scripts/update-readme.js", "version:dev": "npm version prerelease --preid dev --force --no-git-tag-version", "version:patch": "npm version patch --force --no-git-tag-version", "version:minor": "npm version minor --force --no-git-tag-version", "version:major": "npm version major --force --no-git-tag-version", "postversion": "bun run build && bun run readme", "publish:dev": "npm publish --tag dev", "publish:main": "npm publish" }, "files": [ "dist" ], "devDependencies": { "@biomejs/biome": "^2.4.2", "tsdown": "^0.21.4", "typescript": "^5.7.3" }, "peerDependencies": { "@nuxt/kit": ">=3.0.0", "react": ">=17.0.0", "vue": ">=3.0.0" }, "peerDependenciesMeta": { "react": { "optional": true }, "vue": { "optional": true }, "@nuxt/kit": { "optional": true } }, "unpkg": "./dist/lenis.mjs", "main": "./dist/lenis.mjs", "module": "./dist/lenis.mjs", "types": "./dist/lenis.d.ts", "exports": { ".": { "types": "./dist/lenis.d.ts", "default": "./dist/lenis.mjs" }, "./react": { "types": "./dist/lenis-react.d.ts", "default": "./dist/lenis-react.mjs" }, "./snap": { "types": "./dist/lenis-snap.d.ts", "default": "./dist/lenis-snap.mjs" }, "./vue": { "types": "./dist/lenis-vue.d.ts", "default": "./dist/lenis-vue.mjs" }, "./nuxt": { "default": "./dist/lenis-vue-nuxt.mjs" }, "./nuxt/runtime/*": { "default": "./dist/nuxt/runtime/*.mjs" }, "./dist/*": "./dist/*" } } ================================================ FILE: packages/core/browser.ts ================================================ // This file serves as an entry point for the package import { Lenis } from './src/lenis' // @ts-expect-error globalThis.Lenis = Lenis // @ts-expect-error globalThis.Lenis.prototype = Lenis.prototype ================================================ FILE: packages/core/index.ts ================================================ // This file serves as an entry point for the package export { Lenis as default } from './src/lenis' export * from './src/types' ================================================ FILE: packages/core/lenis.css ================================================ html.lenis, html.lenis body { height: auto; } .lenis:not(.lenis-autoToggle).lenis-stopped { overflow: clip; } .lenis [data-lenis-prevent], .lenis [data-lenis-prevent-wheel], .lenis [data-lenis-prevent-touch], .lenis [data-lenis-prevent-vertical], .lenis [data-lenis-prevent-horizontal] { overscroll-behavior: contain; } .lenis.lenis-smooth iframe { pointer-events: none; } .lenis.lenis-autoToggle { transition-property: overflow; transition-duration: 1ms; transition-behavior: allow-discrete; } ================================================ FILE: packages/core/package.json ================================================ { "name": "lenis-core", "type": "module" } ================================================ FILE: packages/core/src/animate.ts ================================================ import { clamp, damp } from './maths' import type { EasingFunction, FromToOptions, OnUpdateCallback } from './types' /** * Animate class to handle value animations with lerping or easing * * @example * const animate = new Animate() * animate.fromTo(0, 100, { duration: 1, easing: (t) => t }) * animate.advance(0.5) // 50 */ export class Animate { isRunning = false value = 0 from = 0 to = 0 currentTime = 0 // These are instanciated in the fromTo method lerp?: number duration?: number easing?: EasingFunction onUpdate?: OnUpdateCallback /** * Advance the animation by the given delta time * * @param deltaTime - The time in seconds to advance the animation */ advance(deltaTime: number) { if (!this.isRunning) return let completed = false if (this.duration && this.easing) { this.currentTime += deltaTime const linearProgress = clamp(0, this.currentTime / this.duration, 1) completed = linearProgress >= 1 const easedProgress = completed ? 1 : this.easing(linearProgress) this.value = this.from + (this.to - this.from) * easedProgress } else if (this.lerp) { this.value = damp(this.value, this.to, this.lerp * 60, deltaTime) if (Math.round(this.value) === this.to) { this.value = this.to completed = true } } else { // If no easing or lerp, just jump to the end value this.value = this.to completed = true } if (completed) { this.stop() } // Call the onUpdate callback with the current value and completed status this.onUpdate?.(this.value, completed) } /** Stop the animation */ stop() { this.isRunning = false } /** * Set up the animation from a starting value to an ending value * with optional parameters for lerping, duration, easing, and onUpdate callback * * @param from - The starting value * @param to - The ending value * @param options - Options for the animation */ fromTo( from: number, to: number, { lerp, duration, easing, onStart, onUpdate }: FromToOptions ) { this.from = this.value = from this.to = to this.lerp = lerp this.duration = duration this.easing = easing this.currentTime = 0 this.isRunning = true onStart?.() this.onUpdate = onUpdate } } ================================================ FILE: packages/core/src/debounce.ts ================================================ export function debounce void>( callback: CB, delay: number ) { let timer: number | undefined return function (this: T, ...args: Parameters) { clearTimeout(timer) timer = setTimeout(() => { timer = undefined callback.apply(this, args) }, delay) } } ================================================ FILE: packages/core/src/dimensions.ts ================================================ import { debounce } from './debounce' /** * Dimensions class to handle the size of the content and wrapper * * @example * const dimensions = new Dimensions(wrapper, content) * dimensions.on('resize', (e) => { * console.log(e.width, e.height) * }) */ export class Dimensions { width = 0 height = 0 scrollHeight = 0 scrollWidth = 0 // These are instanciated in the constructor as they need information from the options private debouncedResize?: (...args: unknown[]) => void private wrapperResizeObserver?: ResizeObserver private contentResizeObserver?: ResizeObserver constructor( private wrapper: HTMLElement | Window | Element, private content: HTMLElement | Element, { autoResize = true, debounce: debounceValue = 250 } = {} ) { if (autoResize) { this.debouncedResize = debounce(this.resize, debounceValue) if (this.wrapper instanceof Window) { window.addEventListener('resize', this.debouncedResize) } else { this.wrapperResizeObserver = new ResizeObserver(this.debouncedResize) this.wrapperResizeObserver.observe(this.wrapper) } this.contentResizeObserver = new ResizeObserver(this.debouncedResize) this.contentResizeObserver.observe(this.content) } this.resize() } destroy() { this.wrapperResizeObserver?.disconnect() this.contentResizeObserver?.disconnect() if (this.wrapper === window && this.debouncedResize) { window.removeEventListener('resize', this.debouncedResize) } } resize = () => { this.onWrapperResize() this.onContentResize() } onWrapperResize = () => { if (this.wrapper instanceof Window) { this.width = window.innerWidth this.height = window.innerHeight } else { this.width = this.wrapper.clientWidth this.height = this.wrapper.clientHeight } } onContentResize = () => { if (this.wrapper instanceof Window) { this.scrollHeight = this.content.scrollHeight this.scrollWidth = this.content.scrollWidth } else { this.scrollHeight = this.wrapper.scrollHeight this.scrollWidth = this.wrapper.scrollWidth } } get limit() { return { x: this.scrollWidth - this.width, y: this.scrollHeight - this.height, } } } ================================================ FILE: packages/core/src/emitter.ts ================================================ /** * Emitter class to handle events * @example * const emitter = new Emitter() * emitter.on('event', (data) => { * console.log(data) * }) * emitter.emit('event', 'data') */ export class Emitter { private events: Record< string, Array<(...args: unknown[]) => void> | undefined > = {} /** * Emit an event with the given data * @param event Event name * @param args Data to pass to the event handlers */ emit(event: string, ...args: unknown[]) { const callbacks = this.events[event] || [] for (let i = 0, length = callbacks.length; i < length; i++) { callbacks[i]?.(...args) } } /** * Add a callback to the event * @param event Event name * @param cb Callback function * @returns Unsubscribe function */ on void>(event: string, cb: CB) { // Add the callback to the event's callback list, or create a new list with the callback if (this.events[event]) { this.events[event].push(cb) } else { this.events[event] = [cb] } // Return an unsubscribe function return () => { this.events[event] = this.events[event]?.filter((i) => cb !== i) } } /** * Remove a callback from the event * @param event Event name * @param callback Callback function */ off void>(event: string, callback: CB) { this.events[event] = this.events[event]?.filter((i) => callback !== i) } /** * Remove all event listeners and clean up */ destroy() { this.events = {} } } ================================================ FILE: packages/core/src/lenis.ts ================================================ import { version } from '../../../package.json' import { Animate } from './animate' import { Dimensions } from './dimensions' import { Emitter } from './emitter' import { clamp, modulo } from './maths' import type { LenisEvent, LenisOptions, ScrollCallback, Scrolling, ScrollToOptions, UserData, VirtualScrollCallback, VirtualScrollData, } from './types' import { VirtualScroll } from './virtual-scroll' // Technical explanation // - listen to 'wheel' events // - prevent 'wheel' event to prevent scroll // - normalize wheel delta // - add delta to targetScroll // - animate scroll to targetScroll (smooth context) // - if animation is not running, listen to 'scroll' events (native context) type OptionalPick = Omit & Partial> const defaultEasing = (t: number) => Math.min(1, 1.001 - 2 ** (-10 * t)) export class Lenis { private _isScrolling: Scrolling = false // true when scroll is animating private _isStopped = false // true if user should not be able to scroll - enable/disable programmatically private _isLocked = false // same as isStopped but enabled/disabled when scroll reaches target private _preventNextNativeScrollEvent = false private _resetVelocityTimeout: ReturnType | null = null private _rafId: number | null = null /** * Whether or not the user is touching the screen */ isTouching?: boolean /** * The time in ms since the lenis instance was created */ time = 0 /** * User data that will be forwarded through the scroll event * * @example * lenis.scrollTo(100, { * userData: { * foo: 'bar' * } * }) */ userData: UserData = {} /** * The last velocity of the scroll */ lastVelocity = 0 /** * The current velocity of the scroll */ velocity = 0 /** * The direction of the scroll */ direction: 1 | -1 | 0 = 0 /** * The options passed to the lenis instance */ options: OptionalPick< Required, | 'duration' | 'easing' | 'prevent' | 'virtualScroll' | '__experimental__naiveDimensions' > /** * The target scroll value */ targetScroll: number /** * The animated scroll value */ animatedScroll: number // These are instanciated here as they don't need information from the options private readonly animate = new Animate() private readonly emitter = new Emitter() // These are instanciated in the constructor as they need information from the options readonly dimensions: Dimensions // This is not private because it's used in the Snap class private readonly virtualScroll: VirtualScroll constructor({ wrapper = window, content = document.documentElement, eventsTarget = wrapper, smoothWheel = true, syncTouch = false, syncTouchLerp = 0.075, touchInertiaExponent = 1.7, duration, // in seconds easing, lerp = 0.1, infinite = false, orientation = 'vertical', // vertical, horizontal gestureOrientation = orientation === 'horizontal' ? 'both' : 'vertical', // vertical, horizontal, both touchMultiplier = 1, wheelMultiplier = 1, autoResize = true, prevent, virtualScroll, overscroll = true, autoRaf = false, anchors = false, autoToggle = false, // https://caniuse.com/?search=transition-behavior allowNestedScroll = false, __experimental__naiveDimensions = false, naiveDimensions = __experimental__naiveDimensions, stopInertiaOnNavigate = false, }: LenisOptions = {}) { // Set version (deprecated) window.lenisVersion = version if (!window.lenis) { window.lenis = {} } window.lenis.version = version if (orientation === 'horizontal') { window.lenis.horizontal = true } if (syncTouch === true) { window.lenis.touch = true } // Check if wrapper is , fallback to window if (!wrapper || wrapper === document.documentElement) { wrapper = window } // flip to easing/time based animation if at least one of them is provided if (typeof duration === 'number' && typeof easing !== 'function') { easing = defaultEasing } else if (typeof easing === 'function' && typeof duration !== 'number') { duration = 1 } // Setup options this.options = { wrapper, content, eventsTarget, smoothWheel, syncTouch, syncTouchLerp, touchInertiaExponent, duration, easing, lerp, infinite, gestureOrientation, orientation, touchMultiplier, wheelMultiplier, autoResize, prevent, virtualScroll, overscroll, autoRaf, anchors, autoToggle, allowNestedScroll, naiveDimensions, stopInertiaOnNavigate, } // Setup dimensions instance this.dimensions = new Dimensions(wrapper, content, { autoResize }) // Setup class name this.updateClassName() // Set the initial scroll value for all scroll information this.targetScroll = this.animatedScroll = this.actualScroll // Add event listeners this.options.wrapper.addEventListener('scroll', this.onNativeScroll) this.options.wrapper.addEventListener('scrollend', this.onScrollEnd, { capture: true, }) if (this.options.anchors || this.options.stopInertiaOnNavigate) { this.options.wrapper.addEventListener( 'click', this.onClick as EventListener ) } this.options.wrapper.addEventListener( 'pointerdown', this.onPointerDown as EventListener ) // Setup virtual scroll instance this.virtualScroll = new VirtualScroll(eventsTarget as HTMLElement, { touchMultiplier, wheelMultiplier, }) this.virtualScroll.on('scroll', this.onVirtualScroll) if (this.options.autoToggle) { this.checkOverflow() this.rootElement.addEventListener('transitionend', this.onTransitionEnd) } if (this.options.autoRaf) { this._rafId = requestAnimationFrame(this.raf) } } /** * Destroy the lenis instance, remove all event listeners and clean up the class name */ destroy() { this.emitter.destroy() this.options.wrapper.removeEventListener('scroll', this.onNativeScroll) this.options.wrapper.removeEventListener('scrollend', this.onScrollEnd, { capture: true, }) this.options.wrapper.removeEventListener( 'pointerdown', this.onPointerDown as EventListener ) if (this.options.anchors || this.options.stopInertiaOnNavigate) { this.options.wrapper.removeEventListener( 'click', this.onClick as EventListener ) } this.virtualScroll.destroy() this.dimensions.destroy() this.cleanUpClassName() if (this._rafId) { cancelAnimationFrame(this._rafId) } } /** * Add an event listener for the given event and callback * * @param event Event name * @param callback Callback function * @returns Unsubscribe function */ on(event: 'scroll', callback: ScrollCallback): () => void on(event: 'virtual-scroll', callback: VirtualScrollCallback): () => void on(event: LenisEvent, callback: ScrollCallback | VirtualScrollCallback) { return this.emitter.on(event, callback as (...args: unknown[]) => void) } /** * Remove an event listener for the given event and callback * * @param event Event name * @param callback Callback function */ off(event: 'scroll', callback: ScrollCallback): void off(event: 'virtual-scroll', callback: VirtualScrollCallback): void off(event: LenisEvent, callback: ScrollCallback | VirtualScrollCallback) { return this.emitter.off(event, callback as (...args: unknown[]) => void) } private onScrollEnd = (e: Event | CustomEvent) => { if (!(e instanceof CustomEvent)) { if (this.isScrolling === 'smooth' || this.isScrolling === false) { e.stopPropagation() } } } private dispatchScrollendEvent = () => { this.options.wrapper.dispatchEvent( new CustomEvent('scrollend', { bubbles: this.options.wrapper === window, // cancelable: false, detail: { lenisScrollEnd: true, }, }) ) } get overflow() { const property = this.isHorizontal ? 'overflow-x' : 'overflow-y' return getComputedStyle(this.rootElement)[ property as keyof CSSStyleDeclaration ] as string } private checkOverflow() { if (['hidden', 'clip'].includes(this.overflow)) { this.internalStop() } else { this.internalStart() } } private onTransitionEnd = (event: TransitionEvent) => { if (event.propertyName.includes('overflow')) { this.checkOverflow() } } private setScroll(scroll: number) { // behavior: 'instant' bypasses the scroll-behavior CSS property if (this.isHorizontal) { this.options.wrapper.scrollTo({ left: scroll, behavior: 'instant' }) } else { this.options.wrapper.scrollTo({ top: scroll, behavior: 'instant' }) } } private onClick = (event: PointerEvent | MouseEvent) => { const path = event.composedPath() // filter anchor elements (elements with a valid href attribute) const linkElements = path.filter( (node) => node instanceof HTMLAnchorElement && node.href ) as HTMLAnchorElement[] const linkElementsUrls = linkElements.map( (element) => new URL(element.href) ) const currentUrl = new URL(window.location.href) if (this.options.anchors) { const anchorElementUrl = linkElementsUrls.find( (targetUrl) => currentUrl.host === targetUrl.host && currentUrl.pathname === targetUrl.pathname && targetUrl.hash ) if (anchorElementUrl) { const options = typeof this.options.anchors === 'object' && this.options.anchors ? this.options.anchors : undefined const target = `#${anchorElementUrl.hash.split('#')[1]}` this.scrollTo(target, options) return } } if (this.options.stopInertiaOnNavigate) { const hasPageLinkElementUrl = linkElementsUrls.some( (targetUrl) => currentUrl.host === targetUrl.host && currentUrl.pathname !== targetUrl.pathname ) if (hasPageLinkElementUrl) { this.reset() return } } } private onPointerDown = (event: PointerEvent | MouseEvent) => { if (event.button === 1) { this.reset() } } private onVirtualScroll = (data: VirtualScrollData) => { if ( typeof this.options.virtualScroll === 'function' && this.options.virtualScroll(data) === false ) return const { deltaX, deltaY, event } = data this.emitter.emit('virtual-scroll', { deltaX, deltaY, event }) // keep zoom feature if (event.ctrlKey) return // @ts-expect-error if (event.lenisStopPropagation) return const isTouch = event.type.includes('touch') const isWheel = event.type.includes('wheel') this.isTouching = event.type === 'touchstart' || event.type === 'touchmove' const isClickOrTap = deltaX === 0 && deltaY === 0 const isTapToStop = this.options.syncTouch && isTouch && event.type === 'touchstart' && isClickOrTap && !this.isStopped && !this.isLocked if (isTapToStop) { this.reset() return } // const isPullToRefresh = // this.options.gestureOrientation === 'vertical' && // this.scroll === 0 && // !this.options.infinite && // deltaY <= 5 // touch pull to refresh, not reliable yet // most likely a touchpad gesture, this keep prev/next page navigation working const isUnknownGesture = (this.options.gestureOrientation === 'vertical' && deltaY === 0) || (this.options.gestureOrientation === 'horizontal' && deltaX === 0) if (isClickOrTap || isUnknownGesture) { return } // catch if scrolling on nested scroll elements let composedPath = event.composedPath() composedPath = composedPath.slice(0, composedPath.indexOf(this.rootElement)) // remove parents elements const prevent = this.options.prevent const gestureOrientation = Math.abs(deltaX) >= Math.abs(deltaY) ? 'horizontal' : 'vertical' if ( composedPath.find( (node) => node instanceof HTMLElement && ((typeof prevent === 'function' && prevent?.(node)) || node.hasAttribute?.('data-lenis-prevent') || (gestureOrientation === 'vertical' && node.hasAttribute?.('data-lenis-prevent-vertical')) || (gestureOrientation === 'horizontal' && node.hasAttribute?.('data-lenis-prevent-horizontal')) || (isTouch && node.hasAttribute?.('data-lenis-prevent-touch')) || (isWheel && node.hasAttribute?.('data-lenis-prevent-wheel')) || (this.options.allowNestedScroll && this.hasNestedScroll(node, { deltaX, deltaY, }))) ) ) return if (this.isStopped || this.isLocked) { if (event.cancelable) { event.preventDefault() // this will stop forwarding the event to the parent, this is problematic } return } const isSmooth = (this.options.syncTouch && isTouch) || (this.options.smoothWheel && isWheel) if (!isSmooth) { this.isScrolling = 'native' this.animate.stop() // @ts-expect-error event.lenisStopPropagation = true return } let delta = deltaY if (this.options.gestureOrientation === 'both') { delta = Math.abs(deltaY) > Math.abs(deltaX) ? deltaY : deltaX } else if (this.options.gestureOrientation === 'horizontal') { delta = deltaX } if ( !this.options.overscroll || this.options.infinite || (this.options.wrapper !== window && this.limit > 0 && ((this.animatedScroll > 0 && this.animatedScroll < this.limit) || (this.animatedScroll === 0 && deltaY > 0) || (this.animatedScroll === this.limit && deltaY < 0))) ) { // @ts-expect-error event.lenisStopPropagation = true // event.stopPropagation() } if (event.cancelable) { event.preventDefault() } const isSyncTouch = isTouch && this.options.syncTouch const isTouchEnd = isTouch && event.type === 'touchend' const hasTouchInertia = isTouchEnd if (hasTouchInertia) { delta = Math.sign(this.velocity) * Math.abs(this.velocity) ** this.options.touchInertiaExponent } this.scrollTo(this.targetScroll + delta, { programmatic: false, ...(isSyncTouch ? { lerp: hasTouchInertia ? this.options.syncTouchLerp : 1, } : { lerp: this.options.lerp, duration: this.options.duration, easing: this.options.easing, }), }) } /** * Force lenis to recalculate the dimensions */ resize() { this.dimensions.resize() this.animatedScroll = this.targetScroll = this.actualScroll this.emit() } private emit() { this.emitter.emit('scroll', this) } private onNativeScroll = () => { if (this._resetVelocityTimeout !== null) { clearTimeout(this._resetVelocityTimeout) this._resetVelocityTimeout = null } if (this._preventNextNativeScrollEvent) { this._preventNextNativeScrollEvent = false return } if (this.isScrolling === false || this.isScrolling === 'native') { const lastScroll = this.animatedScroll this.animatedScroll = this.targetScroll = this.actualScroll this.lastVelocity = this.velocity this.velocity = this.animatedScroll - lastScroll this.direction = Math.sign( this.animatedScroll - lastScroll ) as Lenis['direction'] if (!this.isStopped) { this.isScrolling = 'native' } this.emit() if (this.velocity !== 0) { this._resetVelocityTimeout = setTimeout(() => { this.lastVelocity = this.velocity this.velocity = 0 this.isScrolling = false this.emit() }, 400) } } } private reset() { this.isLocked = false this.isScrolling = false this.animatedScroll = this.targetScroll = this.actualScroll this.lastVelocity = this.velocity = 0 this.animate.stop() } /** * Start lenis scroll after it has been stopped */ start() { if (!this.isStopped) return if (this.options.autoToggle) { this.rootElement.style.removeProperty('overflow') return } this.internalStart() } private internalStart() { if (!this.isStopped) return this.reset() this.isStopped = false this.emit() } /** * Stop lenis scroll */ stop() { if (this.isStopped) return if (this.options.autoToggle) { this.rootElement.style.setProperty('overflow', 'clip') return } this.internalStop() } private internalStop() { if (this.isStopped) return this.reset() this.isStopped = true this.emit() } /** * RequestAnimationFrame for lenis * * @param time The time in ms from an external clock like `requestAnimationFrame` or Tempus */ raf = (time: number) => { const deltaTime = time - (this.time || time) this.time = time this.animate.advance(deltaTime * 0.001) if (this.options.autoRaf) { this._rafId = requestAnimationFrame(this.raf) } } /** * Scroll to a target value * * @param target The target value to scroll to * @param options The options for the scroll * * @example * lenis.scrollTo(100, { * offset: 100, * duration: 1, * easing: (t) => 1 - Math.cos((t * Math.PI) / 2), * lerp: 0.1, * onStart: () => { * console.log('onStart') * }, * onComplete: () => { * console.log('onComplete') * }, * }) */ scrollTo( _target: number | string | HTMLElement, { offset = 0, immediate = false, lock = false, programmatic = true, // called from outside of the class lerp = programmatic ? this.options.lerp : undefined, duration = programmatic ? this.options.duration : undefined, easing = programmatic ? this.options.easing : undefined, onStart, onComplete, force = false, // scroll even if stopped userData, }: ScrollToOptions = {} ) { if ((this.isStopped || this.isLocked) && !force) return let target: number | string | HTMLElement = _target let adjustedOffset = offset // keywords if ( typeof target === 'string' && ['top', 'left', 'start', '#'].includes(target) ) { target = 0 } else if ( typeof target === 'string' && ['bottom', 'right', 'end'].includes(target) ) { target = this.limit } else { let node: Element | null = null if (typeof target === 'string') { // CSS selector node = document.querySelector(target) if (!node) { if (target === '#top') { target = 0 } else { console.warn('Lenis: Target not found', target) } } } else if (target instanceof HTMLElement && target?.nodeType) { // Node element node = target } if (node) { if (this.options.wrapper !== window) { // nested scroll offset correction const wrapperRect = this.rootElement.getBoundingClientRect() adjustedOffset -= this.isHorizontal ? wrapperRect.left : wrapperRect.top } const rect = node.getBoundingClientRect() target = (this.isHorizontal ? rect.left : rect.top) + this.animatedScroll } } if (typeof target !== 'number') return target += adjustedOffset target = Math.round(target) if (this.options.infinite) { if (programmatic) { this.targetScroll = this.animatedScroll = this.scroll const distance = target - this.animatedScroll if (distance > this.limit / 2) { target -= this.limit } else if (distance < -this.limit / 2) { target += this.limit } } } else { target = clamp(0, target, this.limit) } if (target === this.targetScroll) { onStart?.(this) onComplete?.(this) return } this.userData = userData ?? {} if (immediate) { this.animatedScroll = this.targetScroll = target this.setScroll(this.scroll) this.reset() this.preventNextNativeScrollEvent() this.emit() onComplete?.(this) this.userData = {} requestAnimationFrame(() => { this.dispatchScrollendEvent() }) return } if (!programmatic) { this.targetScroll = target } // flip to easing/time based animation if at least one of them is provided if (typeof duration === 'number' && typeof easing !== 'function') { easing = defaultEasing } else if (typeof easing === 'function' && typeof duration !== 'number') { duration = 1 } this.animate.fromTo(this.animatedScroll, target, { duration, easing, lerp, onStart: () => { // started if (lock) this.isLocked = true this.isScrolling = 'smooth' onStart?.(this) }, onUpdate: (value: number, completed: boolean) => { this.isScrolling = 'smooth' // updated this.lastVelocity = this.velocity this.velocity = value - this.animatedScroll this.direction = Math.sign(this.velocity) as Lenis['direction'] this.animatedScroll = value this.setScroll(this.scroll) if (programmatic) { // wheel during programmatic should stop it this.targetScroll = value } if (!completed) this.emit() if (completed) { this.reset() this.emit() onComplete?.(this) this.userData = {} requestAnimationFrame(() => { this.dispatchScrollendEvent() }) // avoid emitting event twice this.preventNextNativeScrollEvent() } }, }) } private preventNextNativeScrollEvent() { this._preventNextNativeScrollEvent = true requestAnimationFrame(() => { this._preventNextNativeScrollEvent = false }) } private hasNestedScroll( node: HTMLElement, { deltaX, deltaY }: { deltaX: number; deltaY: number } ) { const time = Date.now() // @ts-expect-error - _lenis is a custom cache property if (!node._lenis) node._lenis = {} // @ts-expect-error const cache = node._lenis let hasOverflowX: boolean | undefined let hasOverflowY: boolean | undefined let isScrollableX: boolean | undefined let isScrollableY: boolean | undefined let hasOverscrollBehaviorX: boolean | undefined let hasOverscrollBehaviorY: boolean | undefined let scrollWidth: number let scrollHeight: number let clientWidth: number let clientHeight: number if (time - (cache.time ?? 0) > 2000) { cache.time = Date.now() const computedStyle = window.getComputedStyle(node) cache.computedStyle = computedStyle hasOverflowX = ['auto', 'overlay', 'scroll'].includes( computedStyle.overflowX ) hasOverflowY = ['auto', 'overlay', 'scroll'].includes( computedStyle.overflowY ) hasOverscrollBehaviorX = ['auto'].includes( computedStyle.overscrollBehaviorX ) hasOverscrollBehaviorY = ['auto'].includes( computedStyle.overscrollBehaviorY ) cache.hasOverflowX = hasOverflowX cache.hasOverflowY = hasOverflowY if (!(hasOverflowX || hasOverflowY)) return false // if no overflow, it's not scrollable no matter what, early return saves some computations scrollWidth = node.scrollWidth scrollHeight = node.scrollHeight clientWidth = node.clientWidth clientHeight = node.clientHeight isScrollableX = scrollWidth > clientWidth isScrollableY = scrollHeight > clientHeight cache.isScrollableX = isScrollableX cache.isScrollableY = isScrollableY cache.scrollWidth = scrollWidth cache.scrollHeight = scrollHeight cache.clientWidth = clientWidth cache.clientHeight = clientHeight cache.hasOverscrollBehaviorX = hasOverscrollBehaviorX cache.hasOverscrollBehaviorY = hasOverscrollBehaviorY } else { isScrollableX = cache.isScrollableX isScrollableY = cache.isScrollableY hasOverflowX = cache.hasOverflowX hasOverflowY = cache.hasOverflowY scrollWidth = cache.scrollWidth scrollHeight = cache.scrollHeight clientWidth = cache.clientWidth clientHeight = cache.clientHeight hasOverscrollBehaviorX = cache.hasOverscrollBehaviorX hasOverscrollBehaviorY = cache.hasOverscrollBehaviorY } if (!((hasOverflowX && isScrollableX) || (hasOverflowY && isScrollableY))) { return false } const orientation = Math.abs(deltaX) >= Math.abs(deltaY) ? 'horizontal' : 'vertical' let scroll: number | undefined let maxScroll: number | undefined let delta: number | undefined let hasOverflow: boolean | undefined let isScrollable: boolean | undefined let hasOverscrollBehavior: boolean | undefined if (orientation === 'horizontal') { scroll = Math.round(node.scrollLeft) maxScroll = scrollWidth - clientWidth delta = deltaX hasOverflow = hasOverflowX isScrollable = isScrollableX hasOverscrollBehavior = hasOverscrollBehaviorX } else if (orientation === 'vertical') { scroll = Math.round(node.scrollTop) maxScroll = scrollHeight - clientHeight delta = deltaY hasOverflow = hasOverflowY isScrollable = isScrollableY hasOverscrollBehavior = hasOverscrollBehaviorY } else { return false } if (!hasOverscrollBehavior && (scroll >= maxScroll || scroll <= 0)) { return true } const willScroll = delta > 0 ? scroll < maxScroll : scroll > 0 return willScroll && hasOverflow && isScrollable } /** * The root element on which lenis is instanced */ get rootElement() { return ( this.options.wrapper === window ? document.documentElement : this.options.wrapper ) as HTMLElement } /** * The limit which is the maximum scroll value */ get limit() { if (this.options.naiveDimensions) { if (this.isHorizontal) { return this.rootElement.scrollWidth - this.rootElement.clientWidth } return this.rootElement.scrollHeight - this.rootElement.clientHeight } return this.dimensions.limit[this.isHorizontal ? 'x' : 'y'] } /** * Whether or not the scroll is horizontal */ get isHorizontal() { return this.options.orientation === 'horizontal' } /** * The actual scroll value */ get actualScroll() { // value browser takes into account // it has to be this way because of DOCTYPE declaration const wrapper = this.options.wrapper as Window | HTMLElement return this.isHorizontal ? ((wrapper as Window).scrollX ?? (wrapper as HTMLElement).scrollLeft) : ((wrapper as Window).scrollY ?? (wrapper as HTMLElement).scrollTop) } /** * The current scroll value */ get scroll() { return this.options.infinite ? modulo(this.animatedScroll, this.limit) : this.animatedScroll } /** * The progress of the scroll relative to the limit */ get progress() { // avoid progress to be NaN return this.limit === 0 ? 1 : this.scroll / this.limit } /** * Current scroll state */ get isScrolling() { return this._isScrolling } private set isScrolling(value: Scrolling) { if (this._isScrolling !== value) { this._isScrolling = value this.updateClassName() } } /** * Check if lenis is stopped */ get isStopped() { return this._isStopped } private set isStopped(value: boolean) { if (this._isStopped !== value) { this._isStopped = value this.updateClassName() } } /** * Check if lenis is locked */ get isLocked() { return this._isLocked } private set isLocked(value: boolean) { if (this._isLocked !== value) { this._isLocked = value this.updateClassName() } } /** * Check if lenis is smooth scrolling */ get isSmooth() { return this.isScrolling === 'smooth' } /** * The class name applied to the wrapper element */ get className() { let className = 'lenis' if (this.options.autoToggle) className += ' lenis-autoToggle' if (this.isStopped) className += ' lenis-stopped' if (this.isLocked) className += ' lenis-locked' if (this.isScrolling) className += ' lenis-scrolling' if (this.isScrolling === 'smooth') className += ' lenis-smooth' return className } private updateClassName() { this.cleanUpClassName() this.rootElement.className = `${this.rootElement.className} ${this.className}`.trim() } private cleanUpClassName() { this.rootElement.className = this.rootElement.className .replace(/lenis(-\w+)?/g, '') .trim() } } ================================================ FILE: packages/core/src/maths.ts ================================================ /** * Clamp a value between a minimum and maximum value * * @param min Minimum value * @param input Value to clamp * @param max Maximum value * @returns Clamped value */ export function clamp(min: number, input: number, max: number) { return Math.max(min, Math.min(input, max)) } /** * Truncate a floating-point number to a specified number of decimal places * * @param value Value to truncate * @param decimals Number of decimal places to truncate to * @returns Truncated value */ export function truncate(value: number, decimals = 0) { return Number.parseFloat(value.toFixed(decimals)) } /** * Linearly interpolate between two values using an amount (0 <= t <= 1) * * @param x First value * @param y Second value * @param t Amount to interpolate (0 <= t <= 1) * @returns Interpolated value */ export function lerp(x: number, y: number, t: number) { return (1 - t) * x + t * y } /** * Damp a value over time using a damping factor * {@link http://www.rorydriscoll.com/2016/03/07/frame-rate-independent-damping-using-lerp/} * * @param x Initial value * @param y Target value * @param lambda Damping factor * @param dt Time elapsed since the last update * @returns Damped value */ export function damp(x: number, y: number, lambda: number, deltaTime: number) { return lerp(x, y, 1 - Math.exp(-lambda * deltaTime)) } /** * Calculate the modulo of the dividend and divisor while keeping the result within the same sign as the divisor * {@link https://anguscroll.com/just/just-modulo} * * @param n Dividend * @param d Divisor * @returns Modulo */ export function modulo(n: number, d: number) { return ((n % d) + d) % d } ================================================ FILE: packages/core/src/types.ts ================================================ import type { Lenis } from './lenis' export type OnUpdateCallback = (value: number, completed: boolean) => void export type OnStartCallback = () => void export type FromToOptions = { /** * Linear interpolation (lerp) intensity (between 0 and 1) * @default 0.1 */ lerp?: number /** * The duration of the scroll animation (in s) * @default 1 */ duration?: number /** * The easing function to use for the scroll animation * @default (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)) */ easing?: EasingFunction /** * Called when the scroll starts */ onStart?: OnStartCallback /** * Called when the scroll progress changes */ onUpdate?: OnUpdateCallback } export type UserData = Record export type Scrolling = boolean | 'native' | 'smooth' export type LenisEvent = 'scroll' | 'virtual-scroll' export type ScrollCallback = (lenis: Lenis) => void export type VirtualScrollCallback = (data: VirtualScrollData) => void export type VirtualScrollData = { deltaX: number deltaY: number event: WheelEvent | TouchEvent } export type Orientation = 'vertical' | 'horizontal' export type GestureOrientation = 'vertical' | 'horizontal' | 'both' export type EasingFunction = (time: number) => number export type ScrollToOptions = { /** * The offset to apply to the target value * @default 0 */ offset?: number /** * Skip the animation and jump to the target value immediately * @default false */ immediate?: boolean /** * Lock the scroll to the target value * @default false */ lock?: boolean /** * The duration of the scroll animation (in s) */ duration?: number /** * The easing function to use for the scroll animation * @default (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)) */ easing?: EasingFunction /** * Linear interpolation (lerp) intensity (between 0 and 1) * @default 0.1 */ lerp?: number /** * Called when the scroll starts */ onStart?: (lenis: Lenis) => void /** * Called when the scroll completes */ onComplete?: (lenis: Lenis) => void /** * Scroll even if stopped * @default false */ force?: boolean /** * Scroll initiated from outside of the lenis instance * @default false */ programmatic?: boolean /** * User data that will be forwarded through the scroll event */ userData?: UserData } export type LenisOptions = { /** * The element that will be used as the scroll container * @default window */ wrapper?: Window | HTMLElement | Element /** * The element that contains the content that will be scrolled, usually `wrapper`'s direct child * @default document.documentElement */ content?: HTMLElement | Element /** * The element that will listen to `wheel` and `touch` events * @default window */ eventsTarget?: Window | HTMLElement | Element /** * Smooth the scroll initiated by `wheel` events * @default true */ smoothWheel?: boolean /** * Mimic touch device scroll while allowing scroll sync * @default false */ syncTouch?: boolean /** * Linear interpolation (lerp) intensity (between 0 and 1) * @default 0.075 */ syncTouchLerp?: number /** * Manage the the strength of `syncTouch` inertia * @default 1.7 */ touchInertiaExponent?: number /** * Scroll duration in seconds */ duration?: number /** * Scroll easing function * @default (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)) */ easing?: EasingFunction /** * Linear interpolation (lerp) intensity (between 0 and 1) * @default 0.1 */ lerp?: number /** * Enable infinite scrolling * @default false */ infinite?: boolean /** * The orientation of the scrolling. Can be `vertical` or `horizontal` * @default vertical */ orientation?: Orientation /** * The orientation of the gestures. Can be `vertical`, `horizontal` or `both` * @default vertical */ gestureOrientation?: GestureOrientation /** * The multiplier to use for touch events * @default 1 */ touchMultiplier?: number /** * The multiplier to use for mouse wheel events * @default 1 */ wheelMultiplier?: number /** * Resize instance automatically * @default true */ autoResize?: boolean /** * Manually prevent scroll to be smoothed based on elements traversed by events */ prevent?: (node: HTMLElement) => boolean /** * Manually modify the events before they get consumed */ virtualScroll?: (data: VirtualScrollData) => boolean /** * Wether or not to enable overscroll on a nested Lenis instance, similar to CSS overscroll-behavior (https://developer.mozilla.org/en-US/docs/Web/CSS/overscroll-behavior) * @default true */ overscroll?: boolean /** * If `true`, Lenis will automatically run `requestAnimationFrame` loop * @default false */ autoRaf?: boolean /** * If `true`, Lenis will handle anchor links automatically * @default false */ anchors?: boolean | ScrollToOptions /** * If `true`, Lenis will automatically start/stop based on wrapper's overflow property * @default false */ autoToggle?: boolean /** * If `true`, Lenis will allow nested scroll * @default false */ allowNestedScroll?: boolean /** * @deprecated use `naiveDimensions` instead */ __experimental__naiveDimensions?: boolean /** * If `true`, Lenis will use naive dimensions calculation, be careful this has a performance impact * @default false */ naiveDimensions?: boolean /** * If `true`, Lenis will stop inertia when an internal link is clicked * @default false */ stopInertiaOnNavigate?: boolean } declare global { interface Window { lenisVersion: string lenis: { version?: string horizontal?: boolean snap?: boolean touch?: boolean } } } ================================================ FILE: packages/core/src/virtual-scroll.ts ================================================ import { Emitter } from './emitter' import type { VirtualScrollCallback } from './types' const LINE_HEIGHT = 100 / 6 const listenerOptions: AddEventListenerOptions = { passive: false } function getDeltaMultiplier(deltaMode: number, size: number): number { if (deltaMode === 1) return LINE_HEIGHT if (deltaMode === 2) return size return 1 } export class VirtualScroll { touchStart = { x: 0, y: 0, } lastDelta = { x: 0, y: 0, } window = { width: 0, height: 0, } private emitter = new Emitter() constructor( private element: HTMLElement, private options = { wheelMultiplier: 1, touchMultiplier: 1 } ) { window.addEventListener('resize', this.onWindowResize) this.onWindowResize() this.element.addEventListener('wheel', this.onWheel, listenerOptions) this.element.addEventListener( 'touchstart', this.onTouchStart, listenerOptions ) this.element.addEventListener( 'touchmove', this.onTouchMove, listenerOptions ) this.element.addEventListener('touchend', this.onTouchEnd, listenerOptions) } /** * Add an event listener for the given event and callback * * @param event Event name * @param callback Callback function */ on(event: string, callback: VirtualScrollCallback) { return this.emitter.on(event, callback as (...args: unknown[]) => void) } /** Remove all event listeners and clean up */ destroy() { this.emitter.destroy() window.removeEventListener('resize', this.onWindowResize) this.element.removeEventListener('wheel', this.onWheel, listenerOptions) this.element.removeEventListener( 'touchstart', this.onTouchStart, listenerOptions ) this.element.removeEventListener( 'touchmove', this.onTouchMove, listenerOptions ) this.element.removeEventListener( 'touchend', this.onTouchEnd, listenerOptions ) } /** * Event handler for 'touchstart' event * * @param event Touch event */ onTouchStart = (event: TouchEvent) => { // @ts-expect-error - event.targetTouches is not defined const { clientX, clientY } = event.targetTouches ? event.targetTouches[0] : event this.touchStart.x = clientX this.touchStart.y = clientY this.lastDelta = { x: 0, y: 0, } this.emitter.emit('scroll', { deltaX: 0, deltaY: 0, event, }) } /** Event handler for 'touchmove' event */ onTouchMove = (event: TouchEvent) => { // @ts-expect-error - event.targetTouches is not defined const { clientX, clientY } = event.targetTouches ? event.targetTouches[0] : event const deltaX = -(clientX - this.touchStart.x) * this.options.touchMultiplier const deltaY = -(clientY - this.touchStart.y) * this.options.touchMultiplier this.touchStart.x = clientX this.touchStart.y = clientY this.lastDelta = { x: deltaX, y: deltaY, } this.emitter.emit('scroll', { deltaX, deltaY, event, }) } onTouchEnd = (event: TouchEvent) => { this.emitter.emit('scroll', { deltaX: this.lastDelta.x, deltaY: this.lastDelta.y, event, }) } /** Event handler for 'wheel' event */ onWheel = (event: WheelEvent) => { let { deltaX, deltaY, deltaMode } = event const multiplierX = getDeltaMultiplier(deltaMode, this.window.width) const multiplierY = getDeltaMultiplier(deltaMode, this.window.height) deltaX *= multiplierX deltaY *= multiplierY deltaX *= this.options.wheelMultiplier deltaY *= this.options.wheelMultiplier this.emitter.emit('scroll', { deltaX, deltaY, event }) } onWindowResize = () => { this.window = { width: window.innerWidth, height: window.innerHeight, } } } ================================================ FILE: packages/react/README.md ================================================ # lenis/react ## Introduction lenis/react provides a `` component that creates a [Lenis](https://github.com/darkroomengineering/lenis) instance and provides it to its children via context. This allows you to use Lenis in your React app without worrying about passing the instance down through props. It also provides a `useLenis` hook that allows you to access the Lenis instance from any component in your app. ## Installation ```bash npm i lenis ``` ## Usage ### Basic ```jsx import { ReactLenis, useLenis } from 'lenis/react' function App() { const lenis = useLenis((lenis) => { // called every scroll console.log(lenis) }) return ( <> { /* content */ } ) } ``` ## Props - `options`: [Lenis options](https://github.com/darkroomengineering/lenis#instance-settings). - `root`: When `true`, makes the Lenis instance globally accessible via `useLenis` from anywhere in your app (even outside the provider tree). Lenis will use the default `` scroll container. When `'asChild'`, renders wrapper elements for custom scroll containers while still making the instance globally accessible. Default: `false`. ## Hooks Once the Lenis context is set (components mounted inside ``) you can use these handy hooks: `useLenis` is a hook that returns the Lenis instance The hook takes three arguments: - `callback`: The function to be called whenever a scroll event is emitted - `deps`: Trigger callback on change - `priority`: Manage callback execution order ## Examples ### Custom requestAnimationFrame loop: ```jsx import { ReactLenis } from 'lenis/react' import { useEffect, useRef } from 'react' function App() { const lenisRef = useRef() useEffect(() => { function update(time) { lenisRef.current?.lenis?.raf(time) } const rafId = requestAnimationFrame(update) return () => cancelAnimationFrame(rafId) }, []) return ( ) } ``` ### GSAP integration ```jsx import gsap from 'gsap' import { ReactLenis } from 'lenis/react' import { useEffect, useRef } from 'react' function App() { const lenisRef = useRef() useEffect(() => { function update(time) { lenisRef.current?.lenis?.raf(time * 1000) } gsap.ticker.add(update) return () => gsap.ticker.remove(update) }, []) return ( ) } ``` ### Framer Motion integration: ```jsx import { ReactLenis } from 'lenis/react'; import type { LenisRef } from 'lenis/react'; import { cancelFrame, frame } from 'framer-motion'; import { useEffect, useRef } from 'react'; function App() { const lenisRef = useRef(null) useEffect(() => { function update(data: { timestamp: number }) { const time = data.timestamp lenisRef.current?.lenis?.raf(time) } frame.update(update, true) return () => cancelFrame(update) }, []) return ( ) } ``` ## lenis/react in use - [@darkroom.engineering/satus](https://github.com/darkroomengineering/satus) Our starter kit.
## License MIT © [darkroom.engineering](https://github.com/darkroomengineering) ================================================ FILE: packages/react/index.ts ================================================ // This file serves as an entry point for the package export { LenisContext, ReactLenis as default, ReactLenis as Lenis, ReactLenis, } from './src/provider' export * from './src/types' export { useLenis } from './src/use-lenis' ================================================ FILE: packages/react/package.json ================================================ { "name": "lenis-react", "type": "module", "devDependencies": { "@types/react": "^19.0.7", "react": "^19.0.0" } } ================================================ FILE: packages/react/src/provider.tsx ================================================ import Lenis, { type ScrollCallback } from 'lenis' import { type ForwardRefExoticComponent, type RefAttributes, createContext, forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState, } from 'react' import { Store } from './store' import type { LenisContextValue, LenisProps, LenisRef } from './types' export const LenisContext = createContext(null) /** * The root store for the lenis context * * This store serves as a fallback for the context if it is not available * and allows us to use the global lenis instance above a provider */ export const rootLenisContextStore = new Store(null) /** * React component to setup a Lenis instance */ export const ReactLenis: ForwardRefExoticComponent< LenisProps & RefAttributes > = forwardRef( ( { children, root = false, options = {}, autoRaf = true, className = '', ...props }: LenisProps, ref ) => { const wrapperRef = useRef(null) const contentRef = useRef(null) const [lenis, setLenis] = useState(undefined) // Setup ref useImperativeHandle( ref, () => ({ wrapper: wrapperRef.current, content: contentRef.current, lenis, }), [lenis] ) // Setup lenis instance useEffect(() => { const lenis = new Lenis({ ...options, ...(wrapperRef.current && contentRef.current && { wrapper: wrapperRef.current!, content: contentRef.current!, }), autoRaf: options?.autoRaf ?? autoRaf, // this is to avoid breaking the autoRaf prop if it's still used (require breaking change) }) setLenis(lenis) return () => { lenis.destroy() setLenis(undefined) } }, [autoRaf, JSON.stringify({ ...options, wrapper: null, content: null })]) // Handle callbacks const callbacksRefs = useRef< { callback: ScrollCallback priority: number }[] >([]) const addCallback: LenisContextValue['addCallback'] = useCallback( (callback, priority) => { callbacksRefs.current.push({ callback, priority }) callbacksRefs.current.sort((a, b) => a.priority - b.priority) }, [] ) const removeCallback: LenisContextValue['removeCallback'] = useCallback( (callback) => { callbacksRefs.current = callbacksRefs.current.filter( (cb) => cb.callback !== callback ) }, [] ) // This makes sure to set the global context if the root is true useEffect(() => { if (root && lenis) { rootLenisContextStore.set({ lenis, addCallback, removeCallback }) return () => rootLenisContextStore.set(null) } }, [root, lenis, addCallback, removeCallback]) // Setup callback listeners useEffect(() => { if (!lenis) return const onScroll: ScrollCallback = (data) => { for (const { callback } of callbacksRefs.current) { callback(data) } } lenis.on('scroll', onScroll) return () => { lenis.off('scroll', onScroll) } }, [lenis]) if (!children) return null return ( {root && root !== 'asChild' ? ( children ) : (
{children}
)}
) } ) ================================================ FILE: packages/react/src/store.ts ================================================ import { useEffect, useState } from 'react' type Listener = (state: S) => void export class Store { private listeners: Listener[] = [] constructor(private state: S) {} set(state: S) { this.state = state for (const listener of this.listeners) { listener(this.state) } } subscribe(listener: Listener) { this.listeners = [...this.listeners, listener] return () => { this.listeners = this.listeners.filter((l) => l !== listener) } } get() { return this.state } } export function useStore(store: Store) { const [state, setState] = useState(store.get()) useEffect(() => { return store.subscribe((state) => setState(state)) }, [store]) return state } ================================================ FILE: packages/react/src/types.ts ================================================ import type Lenis from 'lenis' import type { LenisOptions, ScrollCallback } from 'lenis' import type { ComponentPropsWithoutRef, ReactNode } from 'react' export type LenisContextValue = { lenis: Lenis addCallback: (callback: ScrollCallback, priority: number) => void removeCallback: (callback: ScrollCallback) => void } export type LenisProps = ComponentPropsWithoutRef<'div'> & { /** * Setup a global instance of Lenis * if `asChild`, the component will render wrapper and content divs * @default false */ root?: boolean | 'asChild' /** * Lenis options */ options?: LenisOptions /** * Auto-setup requestAnimationFrame * @default true * @deprecated use options.autoRaf instead */ autoRaf?: boolean /** * Children */ children?: ReactNode /** * Class name to be applied to the wrapper div * @default '' */ className?: string | undefined } export type LenisRef = { /** * The wrapper div element * * Will only be defined if `root` is `false` or `root` is `asChild` */ wrapper: HTMLDivElement | null /** * The content div element * * Will only be defined if `root` is `false` or `root` is `asChild` */ content: HTMLDivElement | null /** * The lenis instance */ lenis?: Lenis } ================================================ FILE: packages/react/src/use-lenis.ts ================================================ import type Lenis from 'lenis' import type { ScrollCallback } from 'lenis' import { useContext, useEffect } from 'react' import { LenisContext, rootLenisContextStore } from './provider' import { useStore } from './store' import type { LenisContextValue } from './types' // Fall back to an empty object if both context and store are not available const fallbackContext: Partial = {} /** * Hook to access the Lenis instance and its methods * * @example Scroll callback * useLenis((lenis) => { * if (lenis.isScrolling) { * console.log('Scrolling...') * } * * if (lenis.progress === 1) { * console.log('At the end!') * } * }) * * @example Scroll callback with dependencies * useLenis((lenis) => { * if (lenis.isScrolling) { * console.log('Scrolling...', someDependency) * } * }, [someDependency]) * @example Scroll callback with priority * useLenis((lenis) => { * if (lenis.isScrolling) { * console.log('Scrolling...') * } * }, [], 1) * @example Instance access * const lenis = useLenis() * * handleClick() { * lenis.scrollTo(100, { * lerp: 0.1, * duration: 1, * easing: (t) => t, * onComplete: () => { * console.log('Complete!') * } * }) * } */ export function useLenis( callback?: ScrollCallback, deps: unknown[] = [], priority = 0 ): Lenis | undefined { // Try to get the lenis instance from the context first const localContext = useContext(LenisContext) // Fall back to the root store if the context is not available const rootContext = useStore(rootLenisContextStore) // Fall back to the fallback context if all else fails const currentContext = localContext ?? rootContext ?? fallbackContext const { lenis, addCallback, removeCallback } = currentContext useEffect(() => { if (!(callback && addCallback && removeCallback && lenis)) return addCallback(callback, priority) callback(lenis) return () => { removeCallback(callback) } }, [lenis, addCallback, removeCallback, priority, ...deps, callback]) return lenis } ================================================ FILE: packages/snap/README.md ================================================ # lenis/snap ## Introduction lenis/snap provides a partial support for CSS scroll snap with [Lenis](https://github.com/darkroomengineering/lenis), see [Demo](https://lenis.darkroom.engineering/snap) ## Installation ```bash npm i lenis ``` ## Usage ### Basic ```jsx import Lenis from 'lenis' import Snap from 'lenis/snap' const lenis = new Lenis() function raf(time) { lenis.raf(time) requestAnimationFrame(raf) } requestAnimationFrame(raf) const snap = new Snap(lenis) // add snaps points snap.add(500) // snap at 500px snap.add(1000) // snap at 1000px snap.add(1500) // snap at 1500px // or add an element to snap to snap.addElement(document.querySelector('.element'), { align: ['start', 'end'], // 'start', 'center', 'end' }) snap.addElement(document.querySelector('.element1'), { align: 'center', // 'start', 'center', 'end' }) // or add elements at once snap.addElements(document.querySelectorAll('.section'), { align: ['start', 'end'], // 'start', 'center', 'end' }) ``` ### Slideshow ```jsx const snap = new Snap(lenis, { type: 'lock', distanceThreshold: '100%', debounce: 0, }) ``` ## Options - `type`: `proximity` (default), `mandatory` see [scroll-snap-type](https://developer.mozilla.org/en-US/docs/Web/CSS/scroll-snap-type) or `lock`. - `distanceThreshold`: `string | number` (default: '50%'). The distance threshold from the snap point to the scroll position. Ignored when `type` is `mandatory`. If a percentage, it is relative to the viewport size. If a number, it is absolute. - `debounce`: `number` (default: 500). The debounce time for the snap. - `onSnapStart`: `function`. Callback when snap starts. - `onSnapComplete`: `function`. Callback when snap completes. - `lerp`: `number` Lerp value for snapping. (default: lenis lerp). - `easing`: `function`. Easing function for snapping. (default: lenis easing). - `duration`: `number`. Duration for snapping. (default: lenis duration). ## Methods - `add(value: number)`: Add a snap point. - `addElement(element: HTMLElement, options: SnapElementOptions = {})`: Add an element to snap to. - `addElements(elements: HTMLElement[], options: SnapElementOptions = {})`: Add elements at once. - `next()`: Go to the next snap point. - `previous()`: Go to the previous snap point. - `goTo(index: number)`: Go to a specific snap point. - `start()`: Start the snap. - `stop()`: Stop the snap. - `resize()`: Force recalculate the snap points. ================================================ FILE: packages/snap/browser.ts ================================================ // This file serves as an entry point for the package import { Snap } from './src/snap' // @ts-expect-error globalThis.Snap = Snap ================================================ FILE: packages/snap/index.ts ================================================ // This file serves as an entry point for the package export { Snap as default } from './src/snap' export * from './src/types' ================================================ FILE: packages/snap/package.json ================================================ { "name": "lenis-snap", "type": "module" } ================================================ FILE: packages/snap/src/debounce.ts ================================================ export function debounce void>( callback: CB, delay: number ) { let timer: ReturnType | undefined return function (this: T, ...args: Parameters): void { clearTimeout(timer) timer = setTimeout(() => { timer = undefined callback.apply(this, args) }, delay) } } ================================================ FILE: packages/snap/src/element.ts ================================================ import { debounce } from './debounce' function removeParentSticky(element: HTMLElement) { const position = getComputedStyle(element).position const isSticky = position === 'sticky' if (isSticky) { element.style.setProperty('position', 'static') element.dataset.sticky = 'true' } if (element.offsetParent) { removeParentSticky(element.offsetParent as HTMLElement) } } function addParentSticky(element: HTMLElement) { if (element?.dataset?.sticky === 'true') { element.style.removeProperty('position') delete element.dataset.sticky } if (element.offsetParent) { addParentSticky(element.offsetParent as HTMLElement) } } function offsetTop(element: HTMLElement, accumulator = 0) { const top = accumulator + element.offsetTop if (element.offsetParent) { return offsetTop(element.offsetParent as HTMLElement, top) } return top } function offsetLeft(element: HTMLElement, accumulator = 0) { const left = accumulator + element.offsetLeft if (element.offsetParent) { return offsetLeft(element.offsetParent as HTMLElement, left) } return left } function scrollTop(element: HTMLElement, accumulator = 0) { const top = accumulator + element.scrollTop if (element.offsetParent) { return scrollTop(element.offsetParent as HTMLElement, top) } return top + window.scrollY } function scrollLeft(element: HTMLElement, accumulator = 0) { const left = accumulator + element.scrollLeft if (element.offsetParent) { return scrollLeft(element.offsetParent as HTMLElement, left) } return left + window.scrollX } export type SnapElementOptions = { align?: string | string[] ignoreSticky?: boolean ignoreTransform?: boolean } type Rect = { top: number left: number width: number height: number x: number y: number bottom: number right: number element: HTMLElement } export class SnapElement { element: HTMLElement options: SnapElementOptions align: string[] // @ts-expect-error rect: Rect = {} wrapperResizeObserver: ResizeObserver resizeObserver: ResizeObserver debouncedWrapperResize: () => void constructor( element: HTMLElement, { align = ['start'], ignoreSticky = true, ignoreTransform = false, }: SnapElementOptions = {} ) { this.element = element this.options = { align, ignoreSticky, ignoreTransform } this.align = [align].flat() this.debouncedWrapperResize = debounce(this.onWrapperResize, 500) this.wrapperResizeObserver = new ResizeObserver(this.debouncedWrapperResize) this.wrapperResizeObserver.observe(document.body) this.onWrapperResize() this.resizeObserver = new ResizeObserver(this.onResize) this.resizeObserver.observe(this.element) this.setRect({ width: this.element.offsetWidth, height: this.element.offsetHeight, }) } destroy() { this.wrapperResizeObserver.disconnect() this.resizeObserver.disconnect() } setRect({ top, left, width, height, element, }: { top?: number left?: number width?: number height?: number element?: HTMLElement } = {}) { top = top ?? this.rect.top left = left ?? this.rect.left width = width ?? this.rect.width height = height ?? this.rect.height element = element ?? this.rect.element if ( top === this.rect.top && left === this.rect.left && width === this.rect.width && height === this.rect.height && element === this.rect.element ) return this.rect.top = top this.rect.y = top this.rect.width = width this.rect.height = height this.rect.left = left this.rect.x = left this.rect.bottom = top + height this.rect.right = left + width } onWrapperResize = () => { let top: number | undefined let left: number | undefined if (this.options.ignoreSticky) removeParentSticky(this.element) if (this.options.ignoreTransform) { top = offsetTop(this.element) left = offsetLeft(this.element) } else { const rect = this.element.getBoundingClientRect() top = rect.top + scrollTop(this.element) left = rect.left + scrollLeft(this.element) } if (this.options.ignoreSticky) addParentSticky(this.element) this.setRect({ top, left }) } onResize = ([entry]: ResizeObserverEntry[]) => { if (!entry?.borderBoxSize[0]) return const width = entry.borderBoxSize[0].inlineSize const height = entry.borderBoxSize[0].blockSize this.setRect({ width, height }) } } ================================================ FILE: packages/snap/src/snap.ts ================================================ import type Lenis from 'lenis' import type { VirtualScrollData } from 'lenis' import { debounce } from './debounce' import type { SnapElementOptions } from './element' import { SnapElement } from './element' import type { SnapItem, SnapOptions } from './types' import type { UID } from './uid' import { uid } from './uid' // TODO: // - fix wheel scrolling after limits (see console scroll to) // - arrow, spacebar type RequiredPick = Omit & Required> /** * Snap class to handle the snap functionality * * @example * const snap = new Snap(lenis, { * type: 'mandatory', // 'mandatory', 'proximity' or 'lock' * onSnapStart: (snap) => { * console.log('onSnapStart', snap) * }, * onSnapComplete: (snap) => { * console.log('onSnapComplete', snap) * }, * }) * * snap.add(500) // snap at 500px * * const removeSnap = snap.add(500) * * if (someCondition) { * removeSnap() * } */ export class Snap { options: RequiredPick elements = new Map() snaps = new Map() viewport: { width: number; height: number } = { width: window.innerWidth, height: window.innerHeight, } isStopped = false onSnapDebounced: (e: VirtualScrollData) => void currentSnapIndex?: number constructor( private lenis: Lenis, { type = 'proximity', lerp, easing, duration, distanceThreshold = '50%', // useless when type is "mandatory" debounce: debounceDelay = 500, onSnapStart, onSnapComplete, }: SnapOptions = {} ) { if (!window.lenis) { window.lenis = {} } window.lenis.snap = true this.options = { type, lerp, easing, duration, distanceThreshold, debounce: debounceDelay, onSnapStart, onSnapComplete, } this.onWindowResize() window.addEventListener('resize', this.onWindowResize) this.onSnapDebounced = debounce( this.onSnap as (...args: unknown[]) => void, this.options.debounce ) this.lenis.on('virtual-scroll', this.onSnapDebounced) } /** * Destroy the snap instance */ destroy() { this.lenis.off('virtual-scroll', this.onSnapDebounced) window.removeEventListener('resize', this.onWindowResize) this.elements.forEach((element) => { element.destroy() }) } /** * Start the snap after it has been stopped */ start() { this.isStopped = false } /** * Stop the snap */ stop() { this.isStopped = true } /** * Add a snap to the snap instance * * @param value The value to snap to * @param userData User data that will be forwarded through the snap event * @returns Unsubscribe function */ add(value: number): () => void { const id = uid() this.snaps.set(id, { value }) return () => this.snaps.delete(id) } /** * Add an element to the snap instance * * @param element The element to add * @param options The options for the element * @returns Unsubscribe function */ addElement( element: HTMLElement, options: SnapElementOptions = {} ): () => void { const id = uid() this.elements.set(id, new SnapElement(element, options)) return () => this.elements.delete(id) } addElements( elements: HTMLElement[], options: SnapElementOptions = {} ): () => void { const map = [...elements].map((element) => this.addElement(element, options) ) return () => { map.forEach((remove) => { remove() }) } } private onWindowResize = () => { this.viewport.width = window.innerWidth this.viewport.height = window.innerHeight } private computeSnaps = () => { const { isHorizontal } = this.lenis let snaps = [...this.snaps.values()] as SnapItem[] this.elements.forEach(({ rect, align }) => { let value: number | undefined align.forEach((align) => { if (align === 'start') { value = rect.top } else if (align === 'center') { value = isHorizontal ? rect.left + rect.width / 2 - this.viewport.width / 2 : rect.top + rect.height / 2 - this.viewport.height / 2 } else if (align === 'end') { value = isHorizontal ? rect.left + rect.width - this.viewport.width : rect.top + rect.height - this.viewport.height } if (typeof value === 'number') { snaps.push({ value: Math.ceil(value) }) } }) }) snaps = snaps.sort((a, b) => Math.abs(a.value) - Math.abs(b.value)) return snaps } previous() { this.goTo((this.currentSnapIndex ?? 0) - 1) } next() { this.goTo((this.currentSnapIndex ?? 0) + 1) } goTo(index: number) { const snaps = this.computeSnaps() if (snaps.length === 0) return this.currentSnapIndex = Math.max(0, Math.min(index, snaps.length - 1)) const currentSnap = snaps[this.currentSnapIndex] if (currentSnap === undefined) return this.lenis.scrollTo(currentSnap.value, { duration: this.options.duration, easing: this.options.easing, lerp: this.options.lerp, lock: this.options.type === 'lock', userData: { initiator: 'snap' }, onStart: () => { this.options.onSnapStart?.({ index: this.currentSnapIndex, ...currentSnap, }) }, onComplete: () => { this.options.onSnapComplete?.({ index: this.currentSnapIndex, ...currentSnap, }) }, }) } get distanceThreshold() { let distanceThreshold = Number.POSITIVE_INFINITY if (this.options.type === 'mandatory') return Number.POSITIVE_INFINITY const { isHorizontal } = this.lenis const axis = isHorizontal ? 'width' : 'height' if ( typeof this.options.distanceThreshold === 'string' && this.options.distanceThreshold.endsWith('%') ) { distanceThreshold = (Number(this.options.distanceThreshold.replace('%', '')) / 100) * this.viewport[axis] } else if (typeof this.options.distanceThreshold === 'number') { distanceThreshold = this.options.distanceThreshold } else { distanceThreshold = this.viewport[axis] } return distanceThreshold } private onSnap = (e: VirtualScrollData) => { if (this.isStopped) return if (e.event.type === 'touchmove') return if ( this.options.type === 'lock' && this.lenis.userData?.initiator === 'snap' ) return let { scroll, isHorizontal } = this.lenis const delta = isHorizontal ? e.deltaX : e.deltaY scroll = Math.ceil(this.lenis.scroll + delta) const snaps = this.computeSnaps() if (snaps.length === 0) return let snapIndex: number | undefined const prevSnapIndex = snaps.findLastIndex(({ value }) => value < scroll) const nextSnapIndex = snaps.findIndex(({ value }) => value > scroll) if (this.options.type === 'lock') { if (delta > 0) { snapIndex = nextSnapIndex } else if (delta < 0) { snapIndex = prevSnapIndex } } else { const prevSnap = snaps[prevSnapIndex]! const distanceToPrevSnap = prevSnap ? Math.abs(scroll - prevSnap.value) : Number.POSITIVE_INFINITY const nextSnap = snaps[nextSnapIndex]! const distanceToNextSnap = nextSnap ? Math.abs(scroll - nextSnap.value) : Number.POSITIVE_INFINITY snapIndex = distanceToPrevSnap < distanceToNextSnap ? prevSnapIndex : nextSnapIndex } if (snapIndex === undefined) return if (snapIndex === -1) return snapIndex = Math.max(0, Math.min(snapIndex, snaps.length - 1)) const snap = snaps[snapIndex]! const distance = Math.abs(scroll - snap.value) if (distance <= this.distanceThreshold) { this.goTo(snapIndex) } } resize() { this.elements.forEach((element) => { element.onWrapperResize() }) } } ================================================ FILE: packages/snap/src/types.ts ================================================ import type { EasingFunction } from 'lenis' export type SnapItem = { value: number } export type OnSnapCallback = (item: SnapItem & { index?: number }) => void export type SnapOptions = { /** * Snap type * @default 'proximity' */ type?: 'mandatory' | 'proximity' | 'lock' /** * @description Linear interpolation (lerp) intensity (between 0 and 1) */ lerp?: number /** * @description The easing function to use for the snap animation */ easing?: EasingFunction /** * @description The duration of the snap animation (in s) */ duration?: number /** * @default '50%' * @description The distance threshold from the snap point to the scroll position. Ignored when `type` is `mandatory`. If a percentage, it is relative to the viewport size. If a number, it is absolute. */ distanceThreshold?: number | `${number}%` /** * @default 500 * @description The debounce delay (in ms) to prevent snapping too often. */ debounce?: number /** * @description Called when the snap starts */ onSnapStart?: OnSnapCallback /** * @description Called when the snap completes */ onSnapComplete?: OnSnapCallback } ================================================ FILE: packages/snap/src/uid.ts ================================================ let index = 0 export type UID = number export function uid(): UID { return index++ } ================================================ FILE: packages/vue/README.md ================================================ # lenis/vue ## Introduction lenis/vue provides a `` component that creates a [Lenis](https://github.com/darkroomengineering/lenis) instance and provides it to its children via context. This allows you to use Lenis in your Vue app without worrying about passing the instance down through props. It also provides a `useLenis` hook that allows you to access the Lenis instance from any component in your app. lenis/vue provides a vueLenisPlugin that you can use to register the component globally. This allows you to use the component in your templates without having to import it, by using the `vue-lenis` template tag. ## Installation ```bash npm i lenis ``` ### Vue ```js // main.js import { createApp } from 'vue' import LenisVue from 'lenis/vue' const app = createApp({}) app.use(LenisVue) ``` ### Nuxt ```js // nuxt.config.js export default defineNuxtConfig({ modules: ['lenis/nuxt'], }) ``` ## Usage ```vue ``` ## Props - `options`: [Lenis options](https://github.com/darkroomengineering/lenis#instance-settings). - `root`: if `true`, Lenis will be instanciate using `` scroll, then you can use the `useLenis` hook to access the Lenis instance from anywhere in your app. Default: `false`. ## Hooks Once the Lenis context is set (components mounted inside `` or ``) you can use these handy hooks: `useLenis` is a hook that returns the Lenis instance The hook takes two arguments: - `callback`: The function to be called whenever a scroll event is emitted - `priority`: Manage callback execution order ```vue ``` ## Examples ### GSAP integration ```vue ``` ### Motion Integration ```vue ```
## License MIT © [darkroom.engineering](https://github.com/darkroomengineering) ================================================ FILE: packages/vue/index.ts ================================================ // This file serves as an entry point for the package export { VueLenis as Lenis, VueLenis, vueLenisPlugin as default, } from './src/provider' export { useLenis } from './src/use-lenis' ================================================ FILE: packages/vue/nuxt/module.ts ================================================ import { addComponent, addImports, addPlugin, createResolver, defineNuxtModule, } from '@nuxt/kit' // Module options TypeScript interface definition export type ModuleOptions = Record const nuxtModule = defineNuxtModule({ meta: { name: 'lenis/nuxt', configKey: 'lenis', }, // Default configuration options of the Nuxt module defaults: {}, setup(_options, _nuxt) { const { resolve } = createResolver(import.meta.url) addPlugin({ src: resolve('./nuxt/runtime/lenis.mjs'), name: 'lenis', }) addImports({ name: 'useLenis', from: 'lenis/vue' }) addComponent({ name: 'VueLenis', filePath: 'lenis/vue', global: true, export: 'VueLenis', }) }, }) export default nuxtModule export * from 'lenis/vue' ================================================ FILE: packages/vue/nuxt/runtime/lenis.ts ================================================ import vuePlugin from 'lenis/vue' import type { Plugin } from '#app' import { defineNuxtPlugin } from '#imports' const plugin = defineNuxtPlugin({ name: 'lenis', setup(nuxtApp: unknown) { ;(nuxtApp as { vueApp: { use: (plugin: unknown) => void } }).vueApp.use( vuePlugin ) }, }) satisfies Plugin export default plugin ================================================ FILE: packages/vue/nuxt/tsconfig.json ================================================ { "extends": "../../../tsconfig.json", "compilerOptions": { "baseUrl": "./", "paths": { "#imports": ["types/imports.d.ts"], "#app": ["types/app.d.ts"] } } } ================================================ FILE: packages/vue/nuxt/types/app.d.ts ================================================ declare module '#app' { export interface Plugin { name?: string setup: (nuxtApp: unknown) => void } } ================================================ FILE: packages/vue/nuxt/types/imports.d.ts ================================================ import type { Plugin } from '#app' declare module '#imports' { export function defineNuxtPlugin(plugin: Plugin): Plugin } ================================================ FILE: packages/vue/package.json ================================================ { "name": "lenis-vue", "type": "module", "devDependencies": { "vue": "^3.5.13" } } ================================================ FILE: packages/vue/src/provider.ts ================================================ import Lenis, { type LenisOptions, type ScrollCallback } from 'lenis' import type { HTMLAttributes, InjectionKey, Plugin, PropType, ShallowRef, ToRefs, } from 'vue' import { defineComponent, h, onWatcherCleanup, provide, reactive, ref, shallowRef, watch, } from 'vue' import { globalAddCallback, globalLenis, globalRemoveCallback } from './store' export const LenisSymbol: InjectionKey> = Symbol('LenisContext') export const AddCallbackSymbol: InjectionKey< ((callback: ScrollCallback, priority: number) => void) | undefined > = Symbol('AddCallback') export const RemoveCallbackSymbol: InjectionKey< ((callback: ScrollCallback) => void) | undefined > = Symbol('RemoveCallback') export type LenisExposed = { wrapper?: HTMLDivElement content?: HTMLDivElement lenis?: Lenis } const VueLenisImpl = defineComponent({ name: 'VueLenis', props: { root: { type: Boolean as PropType, default: false, }, autoRaf: { type: Boolean as PropType, default: true, }, options: { type: Object as PropType, default: () => ({}), }, props: { type: Object as PropType, default: () => ({}), }, }, setup(props, { slots, expose }) { const lenisRef = shallowRef() const wrapper = ref() const content = ref() // Setup exposed properties expose>({ lenis: lenisRef, wrapper, content, }) // Sync options watch( [() => props.options, wrapper, content], () => { const isClient = typeof window !== 'undefined' if (!isClient) return if (!(props.root || (wrapper.value && content.value))) return lenisRef.value = new Lenis({ ...props.options, ...(!props.root ? { wrapper: wrapper.value, content: content.value, } : {}), autoRaf: props.options?.autoRaf ?? props.autoRaf, }) onWatcherCleanup(() => { lenisRef.value?.destroy() lenisRef.value = undefined }) }, { deep: true, immediate: true } ) const callbacks = reactive< { callback: ScrollCallback; priority: number }[] >([]) function addCallback(callback: ScrollCallback, priority: number) { callbacks.push({ callback, priority }) callbacks.sort((a, b) => a.priority - b.priority) } function removeCallback(callback: ScrollCallback) { callbacks.splice( callbacks.findIndex((cb) => cb.callback === callback), 1 ) } const onScroll: ScrollCallback = (data) => { for (const { callback } of callbacks) { callback(data) } } watch( [lenisRef, () => props.root], ([lenis, root]) => { lenis?.on('scroll', onScroll) if (root) { globalLenis.value = lenis globalAddCallback.value = addCallback globalRemoveCallback.value = removeCallback onWatcherCleanup(() => { globalLenis.value = undefined globalAddCallback.value = undefined globalRemoveCallback.value = undefined }) } }, { immediate: true } ) if (!props.root) { provide(LenisSymbol, lenisRef) provide(AddCallbackSymbol, addCallback) provide(RemoveCallbackSymbol, removeCallback) } return () => { if (props.root) { return slots.default?.() } return h('div', { ref: wrapper, ...props?.props }, [ h('div', { ref: content }, slots.default?.()), ]) } }, }) export const VueLenis = VueLenisImpl as typeof VueLenisImpl & { new (): LenisExposed } export const vueLenisPlugin: Plugin = (app) => { app.component('vue-lenis', VueLenis) // Setup a global provide to silence top level useLenis injection warning app.provide(LenisSymbol, shallowRef(undefined)) app.provide(AddCallbackSymbol, undefined) app.provide(RemoveCallbackSymbol, undefined) } // @ts-expect-error declare module '@vue/runtime-core' { export interface GlobalComponents { 'vue-lenis': typeof VueLenis } } ================================================ FILE: packages/vue/src/store.ts ================================================ import type Lenis from 'lenis' import type { ScrollCallback } from 'lenis' import { shallowRef } from 'vue' export const globalLenis = shallowRef() export const globalAddCallback = shallowRef<(callback: ScrollCallback, priority: number) => void>() export const globalRemoveCallback = shallowRef<(callback: ScrollCallback) => void>() ================================================ FILE: packages/vue/src/use-lenis.ts ================================================ import type Lenis from 'lenis' import type { ScrollCallback } from 'lenis' import { type ComputedRef, computed, inject, nextTick, onWatcherCleanup, watch } from 'vue' import { AddCallbackSymbol, LenisSymbol, RemoveCallbackSymbol, } from './provider' import { globalAddCallback, globalLenis, globalRemoveCallback } from './store' export function useLenis(callback?: ScrollCallback, priority = 0): ComputedRef { const lenisInjection = inject(LenisSymbol) const addCallbackInjection = inject(AddCallbackSymbol) const removeCallbackInjection = inject(RemoveCallbackSymbol) const addCallback = computed(() => addCallbackInjection ? addCallbackInjection : globalAddCallback.value ) const removeCallback = computed(() => removeCallbackInjection ? removeCallbackInjection : globalRemoveCallback.value ) const lenis = computed(() => lenisInjection?.value ? lenisInjection.value : globalLenis.value ) if (typeof window !== 'undefined') { // Wait two ticks to make sure the lenis instance is mounted nextTick(() => { nextTick(() => { // @ts-expect-error - import.meta.env is available in vite and nuxt if (!lenis.value && import.meta.env.DEV) { console.warn( 'No lenis instance found, either mount a root lenis instance or wrap your component in a lenis provider' ) } }) }) } watch( [lenis, addCallback, removeCallback], ([lenis, addCallback, removeCallback]) => { if (!(lenis && addCallback && removeCallback && callback)) return addCallback?.(callback, priority) callback?.(lenis!) onWatcherCleanup(() => { removeCallback?.(callback) }) }, { immediate: true, } ) return lenis } ================================================ FILE: playground/.gitignore ================================================ # build output dist/ # generated types .astro/ # dependencies node_modules/ # logs npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* # environment variables .env .env.production # macOS-specific files .DS_Store # jetbrains setting folder .idea/ env.d.ts ================================================ FILE: playground/astro.config.mjs ================================================ import path from 'node:path' import react from '@astrojs/react' import vue from '@astrojs/vue' import { defineConfig } from 'astro/config' const root = path.resolve('..') export default defineConfig({ integrations: [react(), vue({ appEntrypoint: './vue/setup' })], devToolbar: { enabled: false, }, srcDir: './www', vite: { resolve: { alias: { 'lenis/dist/lenis.css': path.resolve(root, 'dist/lenis.css'), 'lenis/react': path.resolve(root, 'dist/lenis-react.mjs'), 'lenis/snap': path.resolve(root, 'dist/lenis-snap.mjs'), 'lenis/vue': path.resolve(root, 'dist/lenis-vue.mjs'), lenis: path.resolve(root, 'dist/lenis.mjs'), }, }, }, }) ================================================ FILE: playground/core/browser.js ================================================ const lenis = new Lenis() lenis.on('scroll', (e) => { console.log(e) }) function raf(time) { lenis.raf(time) requestAnimationFrame(raf) } requestAnimationFrame(raf) ================================================ FILE: playground/core/static.html ================================================ Lorem ipsum dolor, sit amet consectetur adipisicing elit. Inventore aliquid aliquam quam officiis hic ut voluptatibus dicta perferendis, voluptate iure modi iste ratione explicabo architecto impedit ipsa. Blanditiis, tenetur itaque. ================================================ FILE: playground/core/style.css ================================================ #scroll-to-top { position: fixed; bottom: 20px; right: 20px; padding: 10px; border: 1px solid #ccc; background-color: #fff; cursor: pointer; } #nested-horizontal { width: 50vw; margin: 0 auto; /* height: 200px; */ overflow-x: auto; overscroll-behavior: contain; #nested-horizontal-content { width: 1000px; height: 100%; } } #nested { width: 50vw; margin: 0 auto; height: 200px; overflow-y: auto; overscroll-behavior: contain; /* overflow-x: auto; */ } #debug { position: fixed; top: 4rem; } /* html { overflow: hidden; height: 100%; } body { overflow-y: auto; height: 100%; } */ /* this prevents UI to collapse on scroll */ /* html, body { overflow: hidden; height: 100%; } main { height: 100%; overflow-y: auto; } */ .lenis-stopped { background-color: red; } /* html { overflow: hidden; } */ ================================================ FILE: playground/core/test.ts ================================================ import Lenis from 'lenis' import { LoremIpsum } from 'lorem-ipsum' document.querySelector('#nested-content')!.innerHTML = new LoremIpsum().generateParagraphs(60) document.querySelector('#nested-horizontal-content')!.innerHTML = new LoremIpsum().generateParagraphs(3) document .querySelector('#app')! .insertAdjacentText('afterbegin', new LoremIpsum().generateParagraphs(20)) document .querySelector('#app')! .insertAdjacentText( 'beforeend', `${new LoremIpsum().generateParagraphs(40)}test123` ) // document.querySelector('main')?.addEventListener('scrollend', () => { // console.log('scrollend') // }) window.addEventListener('scroll', (_e) => { // console.log('window scroll', e) }) window.addEventListener('scrollend', (e) => { // console.log('window scrollend', e) }) document.querySelector('#nested')?.addEventListener('scrollend', (_e) => { // console.log('nested scrollend', e) }) window.addEventListener('hashchange', () => { console.log('hashchange') }) const lenis = new Lenis({ smoothWheel: true, autoRaf: true, anchors: { // onStart: () => { // console.log('onStart') // }, // onComplete: () => { // console.log('onComplete') // }, }, autoToggle: true, allowNestedScroll: true, syncTouch: true, infinite: true, stopInertiaOnNavigate: true, // virtualScroll: ({ event }) => { // console.log(lenis.options.syncTouch) // return true // }, // duration: 1, // infinite: true, // lerp: 0.5, // duration: 10, // easing: (t) => t, // syncTouch: true, // lerp: 0.01, // wrapper: document.body, // content: document.querySelector('main'), // wrapper: document.querySelector('main')!, // content: document.querySelector('main')?.children[0], // autoResize: false, // lerp: 0.9, // virtualScroll: (e) => { // // e.deltaY *= 10 // return !e.event.shiftKey // // return true // }, // duration: 2, // easing: (t) => t, // prevent: () => { // return true // }, // prevent: (node) => { // return ( // node.classList?.contains('lenis-scrolling') && // node.classList?.contains('lenis-smooth') && // !node.classList?.contains('lenis-stopped') // ) // }, }) // const _nestedLenis = new Lenis({ // wrapper: document.querySelector('#nested')!, // content: document.querySelector('#nested-content')!, // autoRaf: true, // // overscroll: false, // // orientation: 'horizontal', // // gestureOrientation: 'horizontal', // // infinite: true, // }) lenis.on('scroll', (_e) => { // console.log('scroll', e) }) // document.querySelectorAll('a[href*="#"]').forEach((node) => { // node.addEventListener('click', (e) => { // // lenis.reset() // // e.preventDefault() // // console.log(node.href) // }) // }) // window.addEventListener('hashchange', () => { // console.log('hashchange') // }) // const nestedLenis = new Lenis({ // wrapper: document.querySelector('#nested')!, // content: document.querySelector('#nested-content')!, // autoRaf: true, // // overscroll: false, // // orientation: 'horizontal', // // gestureOrientation: 'horizontal', // // infinite: true, // // smoothWheel: false, // }) // window.nestedLenis = nestedLenis // console.log(lenis.dimensions.height) lenis.on('scroll', (_e) => { // console.log(e.isScrolling) // console.log(e.scroll, e.velocity) // console.log(e.scroll, e.velocity, e.isScrolling, e.userData) }) // lenis.on('virtual-scroll', (e) => { // // console.log(e) // // e.deltaY *= 10 // // e.cancel = true // }) // window.lenis = lenis declare global { interface Window { lenis: Lenis } } // window.addEventListener('resize', () => { // lenis.resize() // console.log(lenis.actualScroll, lenis.scroll, window.scrollY) // }) // Proxy test for lenis // const proxyLenis = new Proxy(lenis, {}) // const scroll100 = document.getElementById('scroll-100') // scroll100?.addEventListener('click', () => { // // proxyLenis?.scrollTo(100, { // // lerp: 0.1, // // }) // lenis.scrollTo(100, { // lerp: 0.1, // }) // }) // document.documentElement.addEventListener('wheel', (e) => { // console.log('wheel') // }) // function raf(time: number) { // lenis.raf(time) // nestedLenis.raf(time) // requestAnimationFrame(raf) // } // requestAnimationFrame(raf) document.getElementById('stop')?.addEventListener('click', () => { // document.documentElement.style.overflow = 'hidden' lenis.stop() }) document.getElementById('start')?.addEventListener('click', () => { // document.documentElement.style.overflow = 'auto' lenis.start() }) document.getElementById('scroll-start')?.addEventListener('click', () => { lenis.scrollTo(100, { lock: true, duration: 1, onComplete: () => { console.log('onComplete') }, }) }) document.getElementById('scroll-center')?.addEventListener('click', () => { lenis.scrollTo(lenis.limit / 2, { // duration: 10, // easing: (t) => t, }) }) document.getElementById('scroll-end')?.addEventListener('click', () => { lenis.scrollTo(lenis.limit - 100) }) // const stopButton = document.getElementById('stop') // const startButton = document.getElementById('start') // stopButton?.addEventListener('click', () => { // lenis.stop() // console.log('stop') // }) // startButton?.addEventListener('click', () => { // lenis.start() // }) ================================================ FILE: playground/horizontal/browser.js ================================================ const lenis = new Lenis() lenis.on('scroll', (e) => { console.log(e) }) function raf(time) { lenis.raf(time) requestAnimationFrame(raf) } requestAnimationFrame(raf) ================================================ FILE: playground/horizontal/static.html ================================================ Lorem ipsum dolor, sit amet consectetur adipisicing elit. Inventore aliquid aliquam quam officiis hic ut voluptatibus dicta perferendis, voluptate iure modi iste ratione explicabo architecto impedit ipsa. Blanditiis, tenetur itaque. ================================================ FILE: playground/horizontal/style.css ================================================ html { overflow-y: clip; } #wrapper { display: flex; height: 100vh; align-items: center; } #about, #work, #work2, #contact { width: 80vw; flex-shrink: 0; display: flex; justify-content: center; align-items: center; height: 100%; } #about { background-color: rgb(255, 152, 162); } #work { background-color: rgb(213, 133, 169); overflow-x: auto; justify-content: flex-start; height: 50%; /* background: repeating-radial-gradient(circle, #000, #111 1px, #000 2px); */ /* filter: blur(2px) brightness(1.2) contrast(2) saturate(3); mix-blend-mode: difference; animation: insane-rotate 0.1s infinite linear; transform: scale(1.001); */ } #work2 { background-color: rgb(255, 152, 162); height: 50%; overflow-y: auto; #work2-content { display: block; } } #contact { background-color: rgb(163, 120, 164); } #work-content { min-width: 100vw; height: 50%; } ================================================ FILE: playground/horizontal/test.ts ================================================ import Lenis from 'lenis' import { LoremIpsum } from 'lorem-ipsum' document.querySelector('#work2-content')!.innerHTML = new LoremIpsum().generateParagraphs(30) new Lenis({ orientation: 'horizontal', // gestureOrientation: 'vertical', autoRaf: true, allowNestedScroll: true, syncTouch: true, anchors: true, stopInertiaOnNavigate: true, // virtualScroll: (data) => { // data.deltaX = 0 // // data.deltaY = 0.00001 // if (data.deltaY === 0 && data.deltaX === 0) { // data.deltaY = 0.00001 // } // console.log(data) // return true // }, }) // setInterval(() => { // document.querySelector('#work').style.width = `${50 + Math.random() * 30}vw` // }, 1000) ================================================ FILE: playground/infinite/browser.js ================================================ const lenis = new Lenis() lenis.on('scroll', (e) => { console.log(e) }) function raf(time) { lenis.raf(time) requestAnimationFrame(raf) } requestAnimationFrame(raf) ================================================ FILE: playground/infinite/static.html ================================================ Lorem ipsum dolor, sit amet consectetur adipisicing elit. Inventore aliquid aliquam quam officiis hic ut voluptatibus dicta perferendis, voluptate iure modi iste ratione explicabo architecto impedit ipsa. Blanditiis, tenetur itaque. ================================================ FILE: playground/infinite/style.css ================================================ html { overflow-x: clip; } img { width: 100%; } ================================================ FILE: playground/infinite/test.ts ================================================ import Lenis from 'lenis' import Stats from 'stats-js' new Lenis({ infinite: true, autoRaf: true, syncTouch: true, }) function isPrime(num: number) { if (num < 2) return false for (let i = 2; i * i <= num; i++) { if (num % i === 0) return false } return true } function sumPrimes(limit: number) { let sum = 0 for (let i = 2; i <= limit; i++) { if (isPrime(i)) { sum += i } } return sum } // function raf() { // // const sum = sumPrimes(Math.random() * 500000) // // console.log(sum) // requestAnimationFrame(raf) // } // raf() // setInterval(() => { // document.querySelector('#work').style.width = `${50 + Math.random() * 30}vw` // }, 1000) var stats = new Stats() stats.showPanel(0) // 0: fps, 1: ms, 2: mb, 3+: custom document.body.appendChild(stats.dom) function animate() { stats.begin() sumPrimes(300000) // monitored code goes here stats.end() requestAnimationFrame(animate) } requestAnimationFrame(animate) ================================================ FILE: playground/nuxt/.gitignore ================================================ # Nuxt dev/build outputs .output .data .nuxt .nitro .cache dist # Node dependencies node_modules # Logs logs *.log # Misc .DS_Store .fleet .idea # Local env files .env .env.* !.env.example ================================================ FILE: playground/nuxt/README.md ================================================ # Nuxt Minimal Starter Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more. ## Setup Make sure to install dependencies: ```bash # npm npm install # pnpm pnpm install # yarn yarn install # bun bun install ``` ## Development Server Start the development server on `http://localhost:3000`: ```bash # npm npm run dev # pnpm pnpm dev # yarn yarn dev # bun bun run dev ``` ## Production Build the application for production: ```bash # npm npm run build # pnpm pnpm build # yarn yarn build # bun bun run build ``` Locally preview production build: ```bash # npm npm run preview # pnpm pnpm preview # yarn yarn preview # bun bun run preview ``` Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information. ================================================ FILE: playground/nuxt/app.vue ================================================ ================================================ FILE: playground/nuxt/components/inner.vue ================================================ ================================================ FILE: playground/nuxt/nuxt.config.ts ================================================ // https://nuxt.com/docs/api/configuration/nuxt-config export default defineNuxtConfig({ compatibilityDate: '2024-11-01', devtools: { enabled: true }, modules: ['lenis/nuxt'], }) ================================================ FILE: playground/nuxt/package.json ================================================ { "name": "playground-nuxt", "type": "module", "scripts": { "build": "nuxt build", "dev": "nuxt dev", "generate": "nuxt generate", "preview": "nuxt preview" }, "dependencies": { "lenis": "*", "nuxt": "^3.15.3", "vue": "latest", "vue-router": "latest", "gsap": "latest" } } ================================================ FILE: playground/nuxt/pages/about.vue ================================================ ================================================ FILE: playground/nuxt/pages/index.vue ================================================ // ================================================ FILE: playground/nuxt/plugins/lenis.ts ================================================ export default defineNuxtPlugin((_nuxtApp) => { // nuxtApp.vueApp.use(LenisVue) }) ================================================ FILE: playground/nuxt/public/robots.txt ================================================ ================================================ FILE: playground/nuxt/server/tsconfig.json ================================================ { "extends": "../.nuxt/tsconfig.server.json" } ================================================ FILE: playground/nuxt/tsconfig.json ================================================ { // https://nuxt.com/docs/guide/concepts/typescript "extends": "./.nuxt/tsconfig.json" } ================================================ FILE: playground/package.json ================================================ { "name": "playground", "type": "module", "version": "0.0.1", "scripts": { "dev": "astro dev --host", "start": "astro dev", "build": "astro check && astro build", "preview": "astro preview", "astro": "astro" }, "dependencies": { "@astrojs/check": "^0.9.4", "@astrojs/react": "^4.1.6", "@astrojs/vue": "^5.0.6", "@types/react": "^19.0.7", "@types/react-dom": "^19.0.3", "astro": "^5.1.8", "lenis": "*", "lorem-ipsum": "^2.0.8", "react": "^19.0.0", "react-dom": "^19.0.0", "stats-js": "1.0.1", "typescript": "^5.7.3", "vue": "^3.5.13" } } ================================================ FILE: playground/react/app.tsx ================================================ import { type LenisRef, ReactLenis, useLenis } from 'lenis/react' import { LoremIpsum } from 'lorem-ipsum' import { useRef, useState } from 'react' function App() { const [lorem] = useState(() => new LoremIpsum().generateParagraphs(200)) const [count, setCount] = useState(0) useLenis() const lenisRef = useRef(null) return (

DOM className:

Scroll, then click the button. Lenis classes should persist.

{lorem}
) } // Poll DOM className outside React to avoid extra renders setInterval(() => { const wrapper = document.querySelector('.lenis') const display = document.getElementById('class-display') if (wrapper && display) { display.textContent = wrapper.className } }, 100) export default App ================================================ FILE: playground/react/style.css ================================================ html:not(.lenis) { body, & { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; } body { position: fixed; overscroll-behavior-y: none; overscroll-behavior-x: none; } .lenis { position: absolute; top: 0; left: 0; width: 100%; height: 100%; overflow: hidden; overflow-y: scroll; -ms-scroll-chaining: none; overscroll-behavior: contain; background: #0b41cd; } } .wrapper.lenis-scrolling { background: #1a6b2a; } .debug-panel { position: fixed; bottom: 12px; right: 12px; z-index: 100; background: rgba(0, 0, 0, 0.85); color: #fff; padding: 16px; border-radius: 8px; font-family: monospace; font-size: 13px; max-width: 400px; } .debug-panel button { padding: 8px 16px; font-size: 14px; cursor: pointer; font-family: monospace; border: 1px solid #555; background: #222; color: #fff; border-radius: 4px; } .debug-panel button:hover { background: #444; } .debug-panel code { display: block; padding: 6px 8px; background: #111; border-radius: 4px; word-break: break-all; color: #4fc3f7; } .debug-panel p { margin: 8px 0 4px; } .debug-panel .hint { color: #999; font-size: 11px; margin-top: 10px; } ================================================ FILE: playground/snap/style.css ================================================ /* html { scroll-snap-type: y mandatory; scroll-behavior: smooth; } .section { scroll-snap-align: start; } */ /* palette from https://twitter.com/tranmautritam/status/1783420779709026785 */ .section { padding: 24px; .inner { height: 100%; width: 100%; border-radius: 16px; } } .section:not(:last-child) { margin-bottom: -24px; } .section-1 { height: 150vh; .inner { background-color: #255bec; } } .section-2 { height: 50vh; .inner { background-color: #f6c74d; } } .section-3 { height: 250vh; .inner { background-color: #b49df8; } } .section-4 { height: 80vh; .inner { background-color: #ea6e38; } } .section-5 { height: 50vh; .inner { background-color: #255bec; } } .section-6 { height: 100vh; .inner { background-color: #f6c74d; } } .inner { display: flex; justify-content: center; align-items: center; } /* #wrapper { height: 80vh; overflow-y: auto; position: relative; top: 10vh; } */ ================================================ FILE: playground/snap/test.ts ================================================ // import { LoremIpsum } from 'lorem-ipsum' import Lenis from 'lenis' import Snap from 'lenis/snap' // import Snap from '../src/index.ts' // document.querySelector('#app').innerHTML = new LoremIpsum().generateParagraphs( // 200 // ) const lenis = new Lenis({ // wrapper: document.querySelector('#wrapper'), // content: document.querySelector('#content'), lerp: 0.1, syncTouch: true, }) const _i = 0 const snap = new Snap(lenis, { type: 'lock', // 'mandatory', 'proximity', 'lock' // velocityThreshold: 1.2, duration: 1, distanceThreshold: '50%', debounce: 500, // duration: 2, // easing: (t) => t, // onSnapStart: (snap) => { // console.log('onSnapStart', snap) // }, // onSnapComplete: (snap) => { // console.log('onSnapComplete', snap) // }, }) declare global { interface Window { snap: Snap } } window.snap = snap const _section1 = document.querySelector('.section-1')! const section2 = document.querySelector('.section-2')! const section3 = document.querySelector('.section-3')! const section4 = document.querySelector('.section-4')! const section5 = document.querySelector('.section-5')! const _section6 = document.querySelector('.section-6')! // snap.add(0, { // index: 0, // }) // snap.add(643, { // index: 1, // }) // snap.addElement(section1, { // align: ['start', 'end'], // }) const _unsub1 = snap.addElement(section2, { align: 'center', }) // console.log('unsub1', unsub1) // unsub1() snap.addElement(section3, { align: ['start', 'end'], }) // snap.addElement(section4, { // align: ['center'], // }) // snap.addElement(section5, { // align: ['center'], // }) const _unsubs = snap.addElements([section4, section5], { align: ['center'], }) // console.log('unsubs', unsubs) // unsubs() // snap.addElement(section6, { // align: ['end'], // }) // snap.addElement(section4, { // align: ['start', 'end'], // 'start', 'center', 'end' // }) function raf(time: number) { lenis.raf(time) requestAnimationFrame(raf) } requestAnimationFrame(raf) ================================================ FILE: playground/tsconfig.json ================================================ { "extends": "astro/tsconfigs/strict", "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "react", "baseUrl": ".", "paths": { "~/*": ["./*"] } } } ================================================ FILE: playground/vue/App.vue ================================================ ================================================ FILE: playground/vue/Child.vue ================================================ ================================================ FILE: playground/vue/InnerChild.vue ================================================ ================================================ FILE: playground/vue/setup.ts ================================================ import LenisVue from 'lenis/vue' import type { App } from 'vue' export default (app: App) => { app.use(LenisVue) } ================================================ FILE: playground/vue/style.css ================================================ ================================================ FILE: playground/www/layouts/Layout.astro ================================================ --- import 'lenis/dist/lenis.css' interface Props { title: string } const { title } = Astro.props --- {title} ================================================ FILE: playground/www/pages/core.astro ================================================ --- import '~/core/style.css' import Layout from '../layouts/Layout.astro' ---

about

Lorem ipsum, dolor sit amet consectetur adipisicing elit. Dolorum, amet illum repellendus vitae nulla provident eveniet totam laborum neque odio veritatis accusantium, hic quam et qui ipsum eos excepturi molestiae?

work

Lorem ipsum, dolor sit amet consectetur adipisicing elit. Dolorum, amet illum repellendus vitae nulla provident eveniet totam laborum neque odio veritatis accusantium, hic quam et qui ipsum eos excepturi molestiae?

contact

Lorem ipsum, dolor sit amet consectetur adipisicing elit. Dolorum, amet illum repellendus vitae nulla provident eveniet totam laborum neque odio veritatis accusantium, hic quam et qui ipsum eos excepturi molestiae?

================================================ FILE: playground/www/pages/horizontal.astro ================================================ --- import '~/horizontal/style.css' import Layout from '../layouts/Layout.astro' ---

about

Lorem ipsum dolor, sit amet consectetur adipisicing elit. Dolore unde hic atque qui nobis tempore quaerat illum laborum, reiciendis maxime natus vero dignissimos. Magnam accusantium sint nulla velit neque magni? Lorem ipsum dolor, sit amet consectetur adipisicing elit. Dolore unde hic atque qui nobis tempore quaerat illum laborum, reiciendis maxime natus vero dignissimos. Magnam accusantium sint nulla velit neque magni?
Lorem ipsum dolor, sit amet consectetur adipisicing elit. Dolore unde hic atque qui nobis tempore quaerat illum laborum, reiciendis maxime natus vero dignissimos. Magnam accusantium sint nulla velit neque magni? Lorem ipsum dolor, sit amet consectetur adipisicing elit. Dolore unde hic atque qui nobis tempore quaerat illum laborum, reiciendis maxime natus vero dignissimos. Magnam accusantium sint nulla velit neque magni?

contact

================================================ FILE: playground/www/pages/index.astro ================================================ --- import Layout from '../layouts/Layout.astro' ---

Welcome to the Lenis Playground!


This is a playground for the Lenis library. It's a great way to test and experiment with the library without having to set up a project.

If you see this, it means you are trying to contribute to the project and we appreciate that!
Playground and build pipeline for all packages run at the same time, so you can change the code and see the changes in real-time in the playground.

================================================ FILE: playground/www/pages/infinite.astro ================================================ --- import '~/infinite/style.css' import Layout from '../layouts/Layout.astro' ---
================================================ FILE: playground/www/pages/react.astro ================================================ --- import App from '~/react/app' import '~/react/style.css' import Layout from '../layouts/Layout.astro' --- ================================================ FILE: playground/www/pages/snap.astro ================================================ --- import '~/snap/style.css' import Layout from '../layouts/Layout.astro' ---
0
1
2
3
4
5
================================================ FILE: playground/www/pages/vue.astro ================================================ --- import App from '~/vue/App.vue' import '~/vue/style.css' import Layout from '../layouts/Layout.astro' --- ================================================ FILE: scripts/update-readme.js ================================================ import fs from 'node:fs' import packageJson from '../package.json' with { type: 'json' } const readmePath = './README.md' function updateVersion() { return new Promise((resolve, reject) => { // update version in README fs.readFile(readmePath, 'utf8', (err, data) => { if (err) { console.log(`Error reading README file: ${err}`) return reject(err) } const updatedReadme = data.replace( /\/lenis@([^/]+)\//g, `/lenis@${packageJson.version}/` ) fs.writeFile(readmePath, updatedReadme, 'utf8', (err) => { resolve() if (err) { return reject(err) } }) }) }) } if (!packageJson.version.includes('-dev')) { updateVersion() } ================================================ FILE: tsconfig.json ================================================ { "exclude": ["dist", "playground", "website"], "compilerOptions": { /* Base Options: */ "skipLibCheck": true, "target": "es2022", "allowJs": true, "resolveJsonModule": true, "moduleDetection": "force", "isolatedModules": true, "verbatimModuleSyntax": true, "moduleResolution": "Bundler", "lib": ["ESNext", "DOM"], "rootDir": ".", "jsx": "react-jsx", /* Strictness */ "strict": true, "noUncheckedIndexedAccess": true, "noImplicitOverride": true, /* If transpiling with TypeScript: */ "module": "ESNext", "outDir": "dist", "sourceMap": true, /* AND if you're building for a library: */ "declaration": true } } ================================================ FILE: tsdown.config.ts ================================================ import { defineConfig } from 'tsdown' const shared = { outDir: 'dist', target: 'es2022' as const, platform: 'browser' as const, format: 'esm' as const, sourcemap: true, outExtensions: () => ({ js: '.mjs', dts: '.d.ts' }), } const iife = (globalName: string, minify = false) => ({ ...shared, format: 'iife' as const, dts: false, clean: false, globalName, minify, outExtensions: undefined, outputOptions: { entryFileNames: minify ? '[name].min.js' : '[name].js', }, }) as const export default defineConfig([ // Core + Snap ESM { ...shared, entry: { lenis: 'packages/core/index.ts', 'lenis-snap': 'packages/snap/index.ts', }, dts: true, clean: true, copy: [{ from: 'packages/core/lenis.css', to: 'dist', flatten: true }], deps: { neverBundle: ['lenis'] }, }, // React ESM { ...shared, entry: { 'lenis-react': 'packages/react/index.ts' }, dts: { resolver: 'tsc' }, clean: false, banner: '"use client";', deps: { neverBundle: ['react', 'lenis'] }, }, // Vue ESM { ...shared, entry: { 'lenis-vue': 'packages/vue/index.ts' }, dts: { resolver: 'tsc' }, clean: false, deps: { neverBundle: ['vue', 'lenis'] }, }, // Nuxt ESM { ...shared, entry: { 'lenis-vue-nuxt': 'packages/vue/nuxt/module.ts', 'nuxt/runtime/lenis': 'packages/vue/nuxt/runtime/lenis.ts', }, dts: false, sourcemap: false, clean: false, deps: { neverBundle: ['lenis', 'lenis/vue', '#imports', '#app', '@nuxt/kit'] }, }, // Browser IIFE builds { entry: { lenis: 'packages/core/browser.ts' }, ...iife('Lenis') }, { entry: { lenis: 'packages/core/browser.ts' }, ...iife('Lenis', true) }, { entry: { 'lenis-snap': 'packages/snap/browser.ts' }, ...iife('Snap') }, { entry: { 'lenis-snap': 'packages/snap/browser.ts' }, ...iife('Snap', true) }, ])