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} <page>",
"**/app/**/layout.tsx": "${dirname(1)}/${dirname} <layout>",
"**/app/api/**/route.ts": "${dirname(1)}/${dirname} <route>",
"**/components/**/index.tsx": "${dirname} <component>"
},
// 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
================================================
[](https://github.com/darkroomengineering/lenis)
[](https://www.npmjs.com/package/lenis)
[](https://www.npmjs.com/package/lenis)
[](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.
<br/>
- [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)
<br/>
## 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.
<a href="https://www.osmo.supply/?utm_source=lenis.dev"><img src="https://www.lenis.dev/sponsors/osmo.png" width="128"/></a>
<br/>
<!-- sponsors -->
[](mailto:jesse@cosmos.so) [](https://github.com/smsunarto) [](https://github.com/bizarro) [](https://github.com/itsoffbrand) [](https://github.com/arkconclave) [](mailto:hello@framerpod.com) [](https://github.com/glauber-sampaio) [](https://github.com/cachet-studio) [](https://github.com/OHO-Design) [](https://github.com/joevingracien) [](mailto:webdesignbylazar@gmail.com)
<!-- sponsors -->
<br/>
<a href="https://vercel.com/oss">
<img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge.svg" />
</a>
<br/>
## 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)
<br/>
## Installation
Using a package manager:
```bash
npm i lenis
# or
yarn add lenis
# or
pnpm add lenis
```
```js
import Lenis from 'lenis'
```
<br/>
Using scripts:
```html
<script src="https://unpkg.com/lenis@1.3.19/dist/lenis.min.js"></script>
```
<br/>
## 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
<link rel="stylesheet" href="https://unpkg.com/lenis@1.3.19/dist/lenis.css">
```
**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).
<br/>
## 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. |
<br/>
<!-- `target`: goal to reach
- `number`: value to scroll in pixels
- `string`: CSS selector or keyword (`top`, `left`, `start`, `bottom`, `right`, `end`)
- `HTMLElement`: DOM element
<br/>
`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 user from scrolling until target reached
- `onComplete`(`function`): called when target is reached -->
## 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 |
<br/>
## 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<ul><li>`number`: value to scroll in pixels</li><li>`string`: CSS selector or keyword (`top`, `left`, `start`, `bottom`, `right`, `end`)</li><li>`HTMLElement`: DOM element</li></ul>`options`<ul><li>`offset`(`number`): equivalent to [`scroll-padding-top`](https://developer.mozilla.org/en-US/docs/Web/CSS/scroll-padding-top)</li><li>`lerp`(`number`): animation lerp intensity</li><li>`duration`(`number`): animation duration (in seconds)</li><li>`easing`(`function`): animation easing</li><li>`immediate`(`boolean`): ignore duration, easing and lerp</li><li>`lock`(`boolean`): whether or not to prevent the user from scrolling until the target is reached</li><li>`force`(`boolean`): reach target even if instance is stopped</li><li>`onComplete`(`function`): called when the target is reached</li><li>`userData`(`object`): this object will be forwarded through `scroll` events</li></ul> |
| `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}` |
<br/>
## 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
<div data-lenis-prevent>scrollable content</div>
```
[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
<div id="modal">scrollable content</div>
```
```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')
}
}
})
```
<br/>
## 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
<br/>
## 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
<br/>
## 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/)
<br/>
## 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/)
<br/>
## 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<CB extends (...args: unknown[]) => void>(
callback: CB,
delay: number
) {
let timer: number | undefined
return function <T>(this: T, ...args: Parameters<typeof callback>) {
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<CB extends (...args: unknown[]) => 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<CB extends (...args: unknown[]) => 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<T, F extends keyof T> = Omit<T, F> & Partial<Pick<T, F>>
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<typeof setTimeout> | 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<LenisOptions>,
| '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 <html>, 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<string, unknown>
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 `<ReactLenis>` 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 (
<>
<ReactLenis root />
{ /* 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 `<html>` 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 `<ReactLenis>`) 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 (
<ReactLenis root options={{ autoRaf: false }} ref={lenisRef} />
)
}
```
### 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 (
<ReactLenis root options={{ autoRaf: false }} ref={lenisRef} />
)
}
```
### 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<LenisRef>(null)
useEffect(() => {
function update(data: { timestamp: number }) {
const time = data.timestamp
lenisRef.current?.lenis?.raf(time)
}
frame.update(update, true)
return () => cancelFrame(update)
}, [])
return (
<ReactLenis root options={{ autoRaf: false }} ref={lenisRef} />
)
}
```
## lenis/react in use
- [@darkroom.engineering/satus](https://github.com/darkroomengineering/satus) Our starter kit.
<br/>
## 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<LenisContextValue | null>(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<LenisContextValue | null>(null)
/**
* React component to setup a Lenis instance
*/
export const ReactLenis: ForwardRefExoticComponent<
LenisProps & RefAttributes<LenisRef>
> = forwardRef<LenisRef, LenisProps>(
(
{
children,
root = false,
options = {},
autoRaf = true,
className = '',
...props
}: LenisProps,
ref
) => {
const wrapperRef = useRef<HTMLDivElement>(null)
const contentRef = useRef<HTMLDivElement>(null)
const [lenis, setLenis] = useState<Lenis | undefined>(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 (
<LenisContext.Provider
value={{ lenis: lenis!, addCallback, removeCallback }}
>
{root && root !== 'asChild' ? (
children
) : (
<div
ref={wrapperRef}
className={`${className} ${lenis?.className ?? ''}`.trim()}
{...props}
>
<div ref={contentRef}>{children}</div>
</div>
)}
</LenisContext.Provider>
)
}
)
================================================
FILE: packages/react/src/store.ts
================================================
import { useEffect, useState } from 'react'
type Listener<S> = (state: S) => void
export class Store<S> {
private listeners: Listener<S>[] = []
constructor(private state: S) {}
set(state: S) {
this.state = state
for (const listener of this.listeners) {
listener(this.state)
}
}
subscribe(listener: Listener<S>) {
this.listeners = [...this.listeners, listener]
return () => {
this.listeners = this.listeners.filter((l) => l !== listener)
}
}
get() {
return this.state
}
}
export function useStore<S>(store: Store<S>) {
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<LenisContextValue> = {}
/**
* Hook to access the Lenis instance and its methods
*
* @example <caption>Scroll callback</caption>
* useLenis((lenis) => {
* if (lenis.isScrolling) {
* console.log('Scrolling...')
* }
*
* if (lenis.progress === 1) {
* console.log('At the end!')
* }
* })
*
* @example <caption>Scroll callback with dependencies</caption>
* useLenis((lenis) => {
* if (lenis.isScrolling) {
* console.log('Scrolling...', someDependency)
* }
* }, [someDependency])
* @example <caption>Scroll callback with priority</caption>
* useLenis((lenis) => {
* if (lenis.isScrolling) {
* console.log('Scrolling...')
* }
* }, [], 1)
* @example <caption>Instance access</caption>
* 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<CB extends (...args: unknown[]) => void>(
callback: CB,
delay: number
) {
let timer: ReturnType<typeof setTimeout> | undefined
return function <T>(this: T, ...args: Parameters<typeof callback>): 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<T, F extends keyof T> = Omit<T, F> & Required<Pick<T, F>>
/**
* 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<SnapOptions, 'type' | 'debounce'>
elements = new Map<UID, SnapElement>()
snaps = new Map<UID, SnapItem>()
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 `<VueLenis>` 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
<script setup>
import { VueLenis, useLenis } from 'lenis/vue' // Also available as global imports, no need to import them manually
import { watch } from 'vue'
const lenisOptions = {
// lenis options (optional)
}
const lenis = useLenis((lenis) => {
// called every scroll
console.log(lenis)
})
watch(
lenis,
(lenis) => {
// lenis instance
console.log(lenis)
},
{ immediate: true }
)
</script>
<template>
<VueLenis root :options="lenisOptions" />
<!-- content -->
</template>
```
## Props
- `options`: [Lenis options](https://github.com/darkroomengineering/lenis#instance-settings).
- `root`: if `true`, Lenis will be instanciate using `<html>` 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 `<VueLenis>` or `<vue-lenis>`) 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
<script setup>
import { VueLenis, useLenis } from 'lenis/vue'
import { watch } from 'vue'
const scrollCallback = (lenis) => {
// called on every scroll
// useLenis provides the lenis instance as an argument
}
const lenis = useLenis(scrollCallback, 0) // where 0 is the default callback priority
</script>
<template>
<VueLenis root />
<!-- content -->
</template>
```
## Examples
### GSAP integration
```vue
<script setup>
import { ref, watchEffect } from 'vue'
import { VueLenis, useLenis } from 'lenis/vue'
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
const lenisRef = ref()
watchEffect((onInvalidate) => {
if (!lenisRef.value?.lenis) return
// if using GSAP ScrollTrigger, update ScrollTrigger on scroll
lenisRef.value.lenis.on('scroll', ScrollTrigger.update)
// add the Lenis requestAnimationFrame (raf) method to GSAP's ticker
// this ensures Lenis's smooth scroll animation updates on each GSAP tick
function update(time) {
lenisRef.value.lenis.raf(time * 1000)
}
gsap.ticker.add(update)
// disable lag smoothing in GSAP to prevent any delay in scroll animations
gsap.ticker.lagSmoothing(0)
// clean up GSAP's ticker from the previous execution of watchEffect, or when the effect is stopped
onInvalidate(() => {
gsap.ticker.remove(update)
})
})
// if using GSAP ScrollTrigger, remember to register the plugin
onMounted(() => {
gsap.registerPlugin(ScrollTrigger)
})
</script>
<template>
<VueLenis root ref="lenisRef" :options="{ autoRaf: false }" />
<!-- content -->
</template>
```
### Motion Integration
```vue
<script setup>
import { VueLenis } from 'lenis/vue'
import { cancelFrame, frame } from 'motion-v'
import { onMounted, onUnmounted, ref } from 'vue'
const lenisRef = ref()
function update({ timestamp }) {
lenisRef.value?.lenis?.raf(timestamp)
}
onMounted(() => {
frame.update(update, true)
})
onUnmounted(() => {
cancelFrame(update)
})
</script>
<template>
<VueLenis ref="lenisRef" root :options="{ autoRaf: false }">
<!-- content -->
</template>
```
<br/>
## 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<string, never>
const nuxtModule = defineNuxtModule<ModuleOptions>({
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<ShallowRef<Lenis | undefined>> =
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<boolean>,
default: false,
},
autoRaf: {
type: Boolean as PropType<boolean>,
default: true,
},
options: {
type: Object as PropType<LenisOptions>,
default: () => ({}),
},
props: {
type: Object as PropType<HTMLAttributes>,
default: () => ({}),
},
},
setup(props, { slots, expose }) {
const lenisRef = shallowRef<Lenis>()
const wrapper = ref<HTMLDivElement>()
const content = ref<HTMLDivElement>()
// Setup exposed properties
expose<ToRefs<LenisExposed>>({
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<Lenis>()
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<Lenis | undefined> {
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
================================================
<html lang="en">
<head>
<style>
body {
height: 300vh;
}
</style>
<script src="../../dist/lenis.min.js"></script>
<script>
const lenis = new Lenis({
autoRaf: true,
})
console.log(lenis)
</script>
</head>
<body>
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.
</body>
</html>
================================================
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
================================================
<html lang="en">
<head>
<style>
body {
height: 300vh;
}
</style>
<script src="../../dist/lenis.min.js"></script>
<script>
const lenis = new Lenis({
autoRaf: true,
})
console.log(lenis)
</script>
</head>
<body>
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.
</body>
</html>
================================================
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
================================================
<html lang="en">
<head>
<style>
body {
height: 300vh;
}
</style>
<script src="../../dist/lenis.min.js"></script>
<script>
const lenis = new Lenis({
autoRaf: true,
})
console.log(lenis)
</script>
</head>
<body>
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.
</body>
</html>
================================================
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
================================================
<script setup lang="ts">
import gsap from 'gsap'
import { watch, watchEffect } from 'vue'
const lenis = useLenis((lenis) => {
console.log('page callback', lenis)
})
watch(
lenis,
(lenis) => {
console.log('page', lenis)
},
{ immediate: true }
)
const lenisRef = useTemplateRef('lenisRef')
watchEffect((onInvalidate) => {
function update(time: number) {
lenisRef.value?.lenis?.raf(time * 1000)
}
gsap.ticker.add(update)
onInvalidate(() => {
gsap.ticker.remove(update)
})
})
</script>
<template>
<nav>
<NuxtLink to="/">Home</NuxtLink>
<NuxtLink to="/about">About</NuxtLink>
</nav>
<VueLenis root :options="{ autoRaf: false }" ref="lenisRef" />
<NuxtPage />
</template>
<style>
* {
margin: 0;
}
</style>
<style scoped>
#app {
padding-top: 24px;
}
nav {
position: fixed;
top: 0;
left: 0;
right: 0;
display: flex;
gap: 1rem;
}
.scroller {
height: 100vh;
overflow-y: auto;
}
</style>
================================================
FILE: playground/nuxt/components/inner.vue
================================================
<script setup>
// import { useLenis } from 'lenis/vue'
// const lenis = useLenis((lenis) => {
// console.log('inner callback', lenis)
// })
// watch(lenis, (lenis) => {
// console.log('inner lenis', lenis)
// })
</script>
<template>
<div>Inner</div>
</template>
================================================
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
================================================
<script setup>
import { useLenis } from 'lenis/vue'
const lenis = useLenis((lenis) => {
console.log('page callback', lenis)
})
watch(lenis, (lenis) => {
console.log('page', lenis)
})
</script>
<template>
<!-- <vue-lenis root /> -->
<!-- <vue-lenis class="scroller"> -->
<div class="content">About <Inner /></div>
<!-- </vue-lenis> -->
</template>
<style scoped>
.content {
/* min-height: 200vh; */
padding-top: 24px;
min-height: 200vh;
}
.scroller {
height: 100vh;
overflow-y: auto;
}
</style>
================================================
FILE: playground/nuxt/pages/index.vue
================================================
//
<script setup>
// import Inner from '../components/inner.vue'
// import { useLenis } from 'lenis/vue'
// import { watchEffect } from 'vue'
// const lenisRef = ref()
// watchEffect(() => {
// console.log('lenisRef', lenisRef.value?.lenis)
// })
//
</script>
<template>
<!-- <vue-lenis class="scroller" ref="lenisRef"> -->
<div class="content">Home <Inner /></div>
<!-- </vue-lenis> -->
</template>
<style scoped>
.content {
/* min-height: 200vh; */
padding-top: 24px;
min-height: 200vh;
}
.scroller {
/* height: 100vh;
overflow-y: auto; */
pointer-events: none;
}
</style>
================================================
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<LenisRef>(null)
return (
<ReactLenis
className={`wrapper a-${count}`}
root="asChild"
ref={lenisRef}
options={{ autoToggle: true }}
>
<div className="debug-panel">
<button type="button" onClick={() => setCount((c) => c + 1)}>
Re-render ({count})
</button>
<p>
<strong>DOM className:</strong>
</p>
<code id="class-display" />
<p className="hint">
Scroll, then click the button. Lenis classes should persist.
</p>
</div>
{lorem}
</ReactLenis>
)
}
// 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<HTMLDivElement>('.section-1')!
const section2 = document.querySelector<HTMLDivElement>('.section-2')!
const section3 = document.querySelector<HTMLDivElement>('.section-3')!
const section4 = document.querySelector<HTMLDivElement>('.section-4')!
const section5 = document.querySelector<HTMLDivElement>('.section-5')!
const _section6 = document.querySelector<HTMLDivElement>('.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
================================================
<script setup>
import { useLenis } from 'lenis/vue'
import { LoremIpsum } from 'lorem-ipsum'
import { ref, watch } from 'vue'
const _lorem = new LoremIpsum().generateParagraphs(200)
const _lerp = ref(0.1)
const _autoRaf = ref(true)
const lenis = useLenis(
(lenis) => {
console.log('root scroll', lenis.options.lerp, lenis.scroll)
},
0,
'root'
)
const lenisRef = ref()
watch(lenis, (lenis) => {
console.log('lenis in callback', lenis)
})
watch(lenisRef, (lenisRef) => {
console.log('lenis in ref', lenisRef.lenis)
})
</script>
<template>
<vue-lenis ref="lenisRef" root :options="{ lerp, autoRaf }">
<Child />
<button @click="lerp += 0.1">more lerp</button>
<button @click="lerp -= 0.1">less lerp</button>
<button @click="lenis.scrollTo(200)">scroll to 200</button>
<button @click="lenisRef.lenis.scrollTo(200)">ref scroll to 200</button>
<vue-lenis
:options="{ lerp: 0.2, autoRaf }"
style="height: 50svh; overflow: scroll"
class="inner"
>
<InnerChild />
</vue-lenis>
<p>
{{ lorem }}
</p>
</vue-lenis>
</template>
<style scoped>
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.vue:hover {
filter: drop-shadow(0 0 2em #42b883aa);
}
</style>
================================================
FILE: playground/vue/Child.vue
================================================
<script setup>
import { useLenis } from 'lenis/vue'
import { watch } from 'vue'
const lenis = useLenis(
(lenis) => {
// TODO: This only works after hot reloading so lenis is not defined on mount here
console.log('child scroll', lenis.options.lerp, lenis.scroll)
},
0,
'child'
)
watch(lenis, (lenis) => {
console.log('Child Lenis lerp:', lenis.options.lerp)
})
</script>
<template>
<button type="button" @click="lenis?.scrollTo(100)">scroll</button>
</template>
================================================
FILE: playground/vue/InnerChild.vue
================================================
<script setup>
import { useLenis } from 'lenis/vue'
import { LoremIpsum } from 'lorem-ipsum'
useLenis((lenis) => {
console.log('innerchild scroll', lenis.options.lerp, lenis.scroll)
})
const _lorem = new LoremIpsum().generateParagraphs(100)
</script>
<template>
<p>
{{ lorem }}
</p>
</template>
================================================
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
---
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width" />
<meta name="generator" content={Astro.generator} />
<title>{title}</title>
</head>
<body>
<nav>
<a href="/">
<svg
width="15"
height="16"
viewBox="0 0 15 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.6273 0C13.4362 0 15 2.35534 15 6.37565C15 11.4112 12.2502 16 6.58123 16L0.221572 16L0 15.7699L2.64556 0.216595L2.86707 0L8.6273 0Z"
fill="#E30613"></path>
</svg>
</a>
<a href="/core">Core</a>
<a href="/snap">Snap</a>
<a href="/react">React</a>
<a href="/vue">Vue</a>
<a href="/horizontal">Horizontal</a>
<a href="/infinite">Infinite</a>
</nav>
<slot />
</body>
</html>
<style is:global>
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
}
/* html {
overflow-y: scroll;
} */
body {
margin: 0;
/* min-width: 320px; */
/* min-height: 100vh; */
}
</style>
<style>
nav {
position: fixed;
top: 2px;
left: 2px;
align-items: center;
border-radius: 0.5rem;
height: 3rem;
display: flex;
gap: 2px;
z-index: 1;
}
a {
min-width: 60px;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
font-family: monospace;
text-transform: uppercase;
color: white;
background-color: #0e0e0e;
text-decoration: none;
border-radius: 4px;
transition: all 0.2s ease-in-out;
padding: 0 1rem;
}
a:hover {
color: #e30613;
border-radius: 12px;
}
body {
/* padding-top: 4rem; */
}
</style>
================================================
FILE: playground/www/pages/core.astro
================================================
---
import '~/core/style.css'
import Layout from '../layouts/Layout.astro'
---
<Layout title="Lenis">
<div id="debug">
<button id="scroll-start">Scroll start</button>
<button id="scroll-center">Scroll center</button>
<button id="scroll-end">Scroll end</button>
<button id="stop">Stop</button>
<button id="start">Start</button>
</div>
<div id="app">
<div id="about">
<h2>about</h2>
<p>
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?
</p>
</div>
<div id="work">
<h2>work</h2>
<p>
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?
</p>
</div>
<div id="contact">
<h2>contact</h2>
<p>
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?
</p>
</div>
<div>
<a href="#"> <span>#</span></a>
<a href="#top"> <span>top</span></a>
<a href="#about"> <span>about</span></a>
<a href="#work"> <span>work</span></a>
<a href="#contact"> <span>contact</span></a>
<a href="/react#a"> <span>void</span></a>
<a
href="https://developer.mozilla.org/en-US/docs/Web/API/HTMLAnchorElement"
target="_blank"
>
<span>mdn</span></a
>
</div>
<div id="nested-horizontal">
<div id="nested-horizontal-content"></div>
</div>
<div id="nested">
<div id="nested-content"></div>
</div>
</div>
<script src="../../core/test.ts"></script>
<!-- THESE ARE HERE TO TEST THE BROWSER VERSION BUNDLE -->
<!-- <script is:raw src="../../node_modules/lenis/dist/lenis.min.js"></script> -->
<!-- <script is:raw src="../../core/browser.js"></script> -->
<!-- <script>
import { LoremIpsum } from 'lorem-ipsum'
document.querySelector('#app').innerHTML = new LoremIpsum().generateParagraphs(30)
</script> -->
</Layout>
================================================
FILE: playground/www/pages/horizontal.astro
================================================
---
import '~/horizontal/style.css'
import Layout from '../layouts/Layout.astro'
---
<Layout title="Lenis">
<div id="wrapper">
<a href="twitter.com" id="about">
<h2>about</h2>
</a>
<div id="work">
<div id="work-content">
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?
</div>
</div>
<div id="work2">
<div id="work2-content">
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?
</div>
</div>
<div id="contact">
<h2>contact</h2>
</div>
</div>
<script src="../../horizontal/test.ts"></script>
<!-- THESE ARE HERE TO TEST THE BROWSER VERSION BUNDLE -->
<!-- <script is:raw src="../../node_modules/lenis/dist/lenis.min.js"></script> -->
<!-- <script is:raw src="../../core/browser.js"></script> -->
<!-- <script>
import { LoremIpsum } from 'lorem-ipsum'
document.querySelector('#app').innerHTML = new LoremIpsum().generateParagraphs(30)
</script> -->
</Layout>
================================================
FILE: playground/www/pages/index.astro
================================================
---
import Layout from '../layouts/Layout.astro'
---
<Layout title="Lenis Playground">
<main>
<h1>Welcome to the Lenis Playground!</h1>
<p>
<br />
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.
<br />
<br />
If you see this, it means you are trying to contribute to the project and we
appreciate that!
<br />
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.
</p>
</main>
</Layout>
<style>
main {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100svh;
width: 50vw;
margin: 0 auto;
}
h1,
p {
margin: 0;
font-family: monospace;
text-align: center;
}
h1 {
text-transform: uppercase;
}
</style>
================================================
FILE: playground/www/pages/infinite.astro
================================================
---
import '~/infinite/style.css'
import Layout from '../layouts/Layout.astro'
---
<Layout title="Lenis">
<div id="wrapper">
<img
src="https://plus.unsplash.com/premium_photo-1746637010097-5e79e6283d99?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
/>
<img
src="https://images.unsplash.com/photo-1747901718105-bf9beb57ba3a?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
/>
<img
src="https://images.unsplash.com/photo-1747901718070-df9e38a96a53?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
/>
<img
src="https://plus.unsplash.com/premium_photo-1747851402163-9183f38a658c?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
/>
<img
src="https://plus.unsplash.com/premium_photo-1747751013539-b1d2a2fdeb7a?q=80&w=2016&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
/>
<img
src="https://images.unsplash.com/photo-1747862240252-50191a60c21d?q=80&w=1965&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
/>
<img
src="https://plus.unsplash.com/premium_photo-1746637010097-5e79e6283d99?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
/>
<img
src="https://images.unsplash.com/photo-1747901718105-bf9beb57ba3a?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
/>
<img
src="https://images.unsplash.com/photo-1747901718070-df9e38a96a53?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
/>
<img
src="https://plus.unsplash.com/premium_photo-1747851402163-9183f38a658c?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
/>
<img
src="https://plus.unsplash.com/premium_photo-1747751013539-b1d2a2fdeb7a?q=80&w=2016&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
/>
<img
src="https://images.unsplash.com/photo-1747862240252-50191a60c21d?q=80&w=1965&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
/>
<img
src="https://plus.unsplash.com/premium_photo-1746637010097-5e79e6283d99?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
/>
<img
src="https://images.unsplash.com/photo-1747901718105-bf9beb57ba3a?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
/>
<img
src="https://images.unsplash.com/photo-1747901718070-df9e38a96a53?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
/>
<img
src="https://plus.unsplash.com/premium_photo-1747851402163-9183f38a658c?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
/>
<img
src="https://plus.unsplash.com/premium_photo-1747751013539-b1d2a2fdeb7a?q=80&w=2016&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
/>
<img
src="https://images.unsplash.com/photo-1747862240252-50191a60c21d?q=80&w=1965&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
/>
</div>
<script src="../../infinite/test.ts"></script>
<!-- THESE ARE HERE TO TEST THE BROWSER VERSION BUNDLE -->
<!-- <script is:raw src="../../node_modules/lenis/dist/lenis.min.js"></script> -->
<!-- <script is:raw src="../../core/browser.js"></script> -->
<!-- <script>
import { LoremIpsum } from 'lorem-ipsum'
document.querySelector('#app').innerHTML = new LoremIpsum().generateParagraphs(30)
</script> -->
</Layout>
================================================
FILE: playground/www/pages/react.astro
================================================
---
import App from '~/react/app'
import '~/react/style.css'
import Layout from '../layouts/Layout.astro'
---
<Layout title="Lenis + React">
<App client:only="react" />
</Layout>
================================================
FILE: playground/www/pages/snap.astro
================================================
---
import '~/snap/style.css'
import Layout from '../layouts/Layout.astro'
---
<Layout title="Lenis Snap">
<div id="wrapper">
<div id="content">
<section class="section section-1">
<div class="inner">0</div>
</section>
<section class="section section-2">
<div class="inner">1</div>
</section>
<section class="section section-3">
<div class="inner">2</div>
</section>
<section class="section section-4">
<div class="inner">3</div>
</section>
<section class="section section-5">
<div class="inner">4</div>
</section>
<section class="section section-6">
<div class="inner">5</div>
</section>
</div>
</div>
<script src="../../snap/test.ts"></script>
</Layout>
================================================
FILE: playground/www/pages/vue.astro
================================================
---
import App from '~/vue/App.vue'
import '~/vue/style.css'
import Layout from '../layouts/Layout.astro'
---
<Layout title="Lenis + Vue">
<App client:only="vue" />
</Layout>
================================================
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) },
])
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
SYMBOL INDEX (137 symbols across 29 files)
FILE: packages/core/src/animate.ts
class Animate (line 12) | class Animate {
method advance (line 30) | advance(deltaTime: number) {
method stop (line 63) | stop() {
method fromTo (line 75) | fromTo(
FILE: packages/core/src/debounce.ts
function debounce (line 1) | function debounce<CB extends (...args: unknown[]) => void>(
FILE: packages/core/src/dimensions.ts
class Dimensions (line 12) | class Dimensions {
method constructor (line 23) | constructor(
method destroy (line 45) | destroy() {
method limit (line 79) | get limit() {
FILE: packages/core/src/emitter.ts
class Emitter (line 10) | class Emitter {
method emit (line 21) | emit(event: string, ...args: unknown[]) {
method on (line 34) | on<CB extends (...args: unknown[]) => void>(event: string, cb: CB) {
method off (line 53) | off<CB extends (...args: unknown[]) => void>(event: string, callback: ...
method destroy (line 60) | destroy() {
FILE: packages/core/src/lenis.ts
type OptionalPick (line 26) | type OptionalPick<T, F extends keyof T> = Omit<T, F> & Partial<Pick<T, F>>
class Lenis (line 30) | class Lenis {
method constructor (line 96) | constructor({
method destroy (line 230) | destroy() {
method on (line 270) | on(event: LenisEvent, callback: ScrollCallback | VirtualScrollCallback) {
method off (line 282) | off(event: LenisEvent, callback: ScrollCallback | VirtualScrollCallbac...
method overflow (line 306) | get overflow() {
method checkOverflow (line 313) | private checkOverflow() {
method setScroll (line 327) | private setScroll(scroll: number) {
method resize (line 544) | resize() {
method emit (line 550) | private emit() {
method reset (line 591) | private reset() {
method start (line 602) | start() {
method internalStart (line 613) | private internalStart() {
method stop (line 624) | stop() {
method internalStop (line 635) | private internalStop() {
method scrollTo (line 679) | scrollTo(
method preventNextNativeScrollEvent (line 846) | private preventNextNativeScrollEvent() {
method hasNestedScroll (line 854) | private hasNestedScroll(
method rootElement (line 977) | get rootElement() {
method limit (line 988) | get limit() {
method isHorizontal (line 1001) | get isHorizontal() {
method actualScroll (line 1008) | get actualScroll() {
method scroll (line 1021) | get scroll() {
method progress (line 1030) | get progress() {
method isScrolling (line 1038) | get isScrolling() {
method isScrolling (line 1042) | private set isScrolling(value: Scrolling) {
method isStopped (line 1052) | get isStopped() {
method isStopped (line 1056) | private set isStopped(value: boolean) {
method isLocked (line 1066) | get isLocked() {
method isLocked (line 1070) | private set isLocked(value: boolean) {
method isSmooth (line 1080) | get isSmooth() {
method className (line 1087) | get className() {
method updateClassName (line 1097) | private updateClassName() {
method cleanUpClassName (line 1104) | private cleanUpClassName() {
FILE: packages/core/src/maths.ts
function clamp (line 9) | function clamp(min: number, input: number, max: number) {
function truncate (line 20) | function truncate(value: number, decimals = 0) {
function lerp (line 32) | function lerp(x: number, y: number, t: number) {
function damp (line 46) | function damp(x: number, y: number, lambda: number, deltaTime: number) {
function modulo (line 58) | function modulo(n: number, d: number) {
FILE: packages/core/src/types.ts
type OnUpdateCallback (line 3) | type OnUpdateCallback = (value: number, completed: boolean) => void
type OnStartCallback (line 4) | type OnStartCallback = () => void
type FromToOptions (line 6) | type FromToOptions = {
type UserData (line 32) | type UserData = Record<string, unknown>
type Scrolling (line 34) | type Scrolling = boolean | 'native' | 'smooth'
type LenisEvent (line 36) | type LenisEvent = 'scroll' | 'virtual-scroll'
type ScrollCallback (line 37) | type ScrollCallback = (lenis: Lenis) => void
type VirtualScrollCallback (line 38) | type VirtualScrollCallback = (data: VirtualScrollData) => void
type VirtualScrollData (line 40) | type VirtualScrollData = {
type Orientation (line 46) | type Orientation = 'vertical' | 'horizontal'
type GestureOrientation (line 47) | type GestureOrientation = 'vertical' | 'horizontal' | 'both'
type EasingFunction (line 48) | type EasingFunction = (time: number) => number
type ScrollToOptions (line 50) | type ScrollToOptions = {
type LenisOptions (line 104) | type LenisOptions = {
type Window (line 234) | interface Window {
FILE: packages/core/src/virtual-scroll.ts
constant LINE_HEIGHT (line 4) | const LINE_HEIGHT = 100 / 6
function getDeltaMultiplier (line 7) | function getDeltaMultiplier(deltaMode: number, size: number): number {
class VirtualScroll (line 13) | class VirtualScroll {
method constructor (line 28) | constructor(
method on (line 55) | on(event: string, callback: VirtualScrollCallback) {
method destroy (line 60) | destroy() {
FILE: packages/react/src/store.ts
type Listener (line 3) | type Listener<S> = (state: S) => void
class Store (line 5) | class Store<S> {
method constructor (line 8) | constructor(private state: S) {}
method set (line 10) | set(state: S) {
method subscribe (line 18) | subscribe(listener: Listener<S>) {
method get (line 25) | get() {
function useStore (line 30) | function useStore<S>(store: Store<S>) {
FILE: packages/react/src/types.ts
type LenisContextValue (line 5) | type LenisContextValue = {
type LenisProps (line 11) | type LenisProps = ComponentPropsWithoutRef<'div'> & {
type LenisRef (line 40) | type LenisRef = {
FILE: packages/react/src/use-lenis.ts
function useLenis (line 51) | function useLenis(
FILE: packages/snap/src/debounce.ts
function debounce (line 1) | function debounce<CB extends (...args: unknown[]) => void>(
FILE: packages/snap/src/element.ts
function removeParentSticky (line 3) | function removeParentSticky(element: HTMLElement) {
function addParentSticky (line 18) | function addParentSticky(element: HTMLElement) {
function offsetTop (line 29) | function offsetTop(element: HTMLElement, accumulator = 0) {
function offsetLeft (line 37) | function offsetLeft(element: HTMLElement, accumulator = 0) {
function scrollTop (line 45) | function scrollTop(element: HTMLElement, accumulator = 0) {
function scrollLeft (line 53) | function scrollLeft(element: HTMLElement, accumulator = 0) {
type SnapElementOptions (line 61) | type SnapElementOptions = {
type Rect (line 67) | type Rect = {
class SnapElement (line 79) | class SnapElement {
method constructor (line 89) | constructor(
method destroy (line 117) | destroy() {
method setRect (line 122) | setRect({
FILE: packages/snap/src/snap.ts
type RequiredPick (line 14) | type RequiredPick<T, F extends keyof T> = Omit<T, F> & Required<Pick<T, F>>
class Snap (line 38) | class Snap {
method constructor (line 50) | constructor(
method destroy (line 94) | destroy() {
method start (line 105) | start() {
method stop (line 112) | stop() {
method add (line 123) | add(value: number): () => void {
method addElement (line 138) | addElement(
method addElements (line 149) | addElements(
method previous (line 200) | previous() {
method next (line 204) | next() {
method goTo (line 208) | goTo(index: number) {
method distanceThreshold (line 239) | get distanceThreshold() {
method resize (line 321) | resize() {
FILE: packages/snap/src/types.ts
type SnapItem (line 3) | type SnapItem = {
type OnSnapCallback (line 7) | type OnSnapCallback = (item: SnapItem & { index?: number }) => void
type SnapOptions (line 9) | type SnapOptions = {
FILE: packages/snap/src/uid.ts
type UID (line 3) | type UID = number
function uid (line 5) | function uid(): UID {
FILE: packages/vue/nuxt/module.ts
type ModuleOptions (line 10) | type ModuleOptions = Record<string, never>
method setup (line 19) | setup(_options, _nuxt) {
FILE: packages/vue/nuxt/runtime/lenis.ts
method setup (line 7) | setup(nuxtApp: unknown) {
FILE: packages/vue/nuxt/types/app.d.ts
type Plugin (line 2) | interface Plugin {
FILE: packages/vue/src/provider.ts
type LenisExposed (line 31) | type LenisExposed = {
method setup (line 57) | setup(props, { slots, expose }) {
type GlobalComponents (line 170) | interface GlobalComponents {
FILE: packages/vue/src/use-lenis.ts
function useLenis (line 11) | function useLenis(callback?: ScrollCallback, priority = 0): ComputedRef<...
FILE: playground/core/browser.js
function raf (line 7) | function raf(time) {
FILE: playground/core/test.ts
type Window (line 145) | interface Window {
FILE: playground/horizontal/browser.js
function raf (line 7) | function raf(time) {
FILE: playground/infinite/browser.js
function raf (line 7) | function raf(time) {
FILE: playground/infinite/test.ts
function isPrime (line 10) | function isPrime(num: number) {
function sumPrimes (line 18) | function sumPrimes(limit: number) {
function animate (line 44) | function animate() {
FILE: playground/react/app.tsx
function App (line 5) | function App() {
FILE: playground/snap/test.ts
type Window (line 36) | interface Window {
function raf (line 96) | function raf(time: number) {
FILE: scripts/update-readme.js
function updateVersion (line 6) | function updateVersion() {
Condensed preview — 100 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (195K chars).
[
{
"path": ".github/CODEOWNERS",
"chars": 28,
"preview": "* @darkroomengineering/devs\n"
},
{
"path": ".github/FUNDING.yml",
"chars": 57,
"preview": "github: [darkroomengineering]\npolar: darkroomengineering\n"
},
{
"path": ".github/ISSUE_TEMPLATE/bug_report.md",
"chars": 537,
"preview": "## Before to submit your issue\nRead the [Troubleshooting](https://github.com/darkroomengineering/lenis#troubleshooting) "
},
{
"path": ".github/dependabot.yml",
"chars": 513,
"preview": "# To get started with Dependabot version updates, you'll need to specify which\n# package ecosystems to update and where "
},
{
"path": ".gitignore",
"chars": 539,
"preview": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\nnode_modules\n/.pnp"
},
{
"path": ".vscode/extensions.json",
"chars": 385,
"preview": "{\n \"recommendations\": [\n // Formatting & Linting\n \"biomejs.biome\",\n\n // CSS & Styling\n \"bradlc.vscode-tailw"
},
{
"path": ".vscode/settings.json",
"chars": 2052,
"preview": "{\n // Formatting\n \"editor.formatOnSave\": true,\n \"editor.defaultFormatter\": \"biomejs.biome\",\n \"editor.codeActionsOnSa"
},
{
"path": "CONTRIBUTING.md",
"chars": 1367,
"preview": "# Lenis Contributing Guide\n\nYooo! We're really excited that you're interested in contributing to Lenis! Before submittin"
},
{
"path": "LICENSE",
"chars": 1080,
"preview": "The MIT License\n\nCopyright (c) 2024 darkroom.engineering\n\nPermission is hereby granted, free of charge, to any person ob"
},
{
"path": "MANIFESTO.md",
"chars": 2258,
"preview": "# For the Nerds 🧠\nAlright, let's get nerdy for a minute because you probably installed Lenis for smooth scrolling and do"
},
{
"path": "README.md",
"chars": 32310,
"preview": "[](https://github.com/darkroomengineering/lenis)\n\n[.lenis-stopped {\n overflow: clip;\n}\n\n.len"
},
{
"path": "packages/core/package.json",
"chars": 47,
"preview": "{\n \"name\": \"lenis-core\",\n \"type\": \"module\"\n}\n"
},
{
"path": "packages/core/src/animate.ts",
"chars": 2339,
"preview": "import { clamp, damp } from './maths'\nimport type { EasingFunction, FromToOptions, OnUpdateCallback } from './types'\n\n/*"
},
{
"path": "packages/core/src/debounce.ts",
"chars": 338,
"preview": "export function debounce<CB extends (...args: unknown[]) => void>(\n callback: CB,\n delay: number\n) {\n let timer: numb"
},
{
"path": "packages/core/src/dimensions.ts",
"chars": 2291,
"preview": "import { debounce } from './debounce'\n\n/**\n * Dimensions class to handle the size of the content and wrapper\n *\n * @exam"
},
{
"path": "packages/core/src/emitter.ts",
"chars": 1572,
"preview": "/**\n * Emitter class to handle events\n * @example\n * const emitter = new Emitter()\n * emitter.on('event', (data) => {\n *"
},
{
"path": "packages/core/src/lenis.ts",
"chars": 29515,
"preview": "import { version } from '../../../package.json'\nimport { Animate } from './animate'\nimport { Dimensions } from './dimens"
},
{
"path": "packages/core/src/maths.ts",
"chars": 1668,
"preview": "/**\n * Clamp a value between a minimum and maximum value\n *\n * @param min Minimum value\n * @param input Value to clamp\n "
},
{
"path": "packages/core/src/types.ts",
"chars": 5910,
"preview": "import type { Lenis } from './lenis'\n\nexport type OnUpdateCallback = (value: number, completed: boolean) => void\nexport "
},
{
"path": "packages/core/src/virtual-scroll.ts",
"chars": 3856,
"preview": "import { Emitter } from './emitter'\nimport type { VirtualScrollCallback } from './types'\n\nconst LINE_HEIGHT = 100 / 6\nco"
},
{
"path": "packages/react/README.md",
"chars": 3321,
"preview": "# lenis/react\n\n## Introduction\nlenis/react provides a `<ReactLenis>` component that creates a [Lenis](https://github.com"
},
{
"path": "packages/react/index.ts",
"chars": 236,
"preview": "// This file serves as an entry point for the package\nexport {\n LenisContext,\n ReactLenis as default,\n ReactLenis as "
},
{
"path": "packages/react/package.json",
"chars": 130,
"preview": "{\n \"name\": \"lenis-react\",\n \"type\": \"module\",\n \"devDependencies\": {\n \"@types/react\": \"^19.0.7\",\n \"react\": \"^19.0"
},
{
"path": "packages/react/src/provider.tsx",
"chars": 3785,
"preview": "import Lenis, { type ScrollCallback } from 'lenis'\nimport {\n type ForwardRefExoticComponent,\n type RefAttributes,\n cr"
},
{
"path": "packages/react/src/store.ts",
"chars": 740,
"preview": "import { useEffect, useState } from 'react'\n\ntype Listener<S> = (state: S) => void\n\nexport class Store<S> {\n private li"
},
{
"path": "packages/react/src/types.ts",
"chars": 1289,
"preview": "import type Lenis from 'lenis'\nimport type { LenisOptions, ScrollCallback } from 'lenis'\nimport type { ComponentPropsWit"
},
{
"path": "packages/react/src/use-lenis.ts",
"chars": 2432,
"preview": "import type Lenis from 'lenis'\nimport type { ScrollCallback } from 'lenis'\nimport { useContext, useEffect } from 'react'"
},
{
"path": "packages/snap/README.md",
"chars": 2646,
"preview": "# lenis/snap\r\n\r\n## Introduction\r\nlenis/snap provides a partial support for CSS scroll snap with [Lenis](https://github.c"
},
{
"path": "packages/snap/browser.ts",
"chars": 132,
"preview": "// This file serves as an entry point for the package\nimport { Snap } from './src/snap'\n\n// @ts-expect-error\nglobalThis."
},
{
"path": "packages/snap/index.ts",
"chars": 127,
"preview": "// This file serves as an entry point for the package\nexport { Snap as default } from './src/snap'\nexport * from './src/"
},
{
"path": "packages/snap/package.json",
"chars": 47,
"preview": "{\n \"name\": \"lenis-snap\",\n \"type\": \"module\"\n}\n"
},
{
"path": "packages/snap/src/debounce.ts",
"chars": 367,
"preview": "export function debounce<CB extends (...args: unknown[]) => void>(\n callback: CB,\n delay: number\n) {\n let timer: Retu"
},
{
"path": "packages/snap/src/element.ts",
"chars": 4549,
"preview": "import { debounce } from './debounce'\n\nfunction removeParentSticky(element: HTMLElement) {\n const position = getCompute"
},
{
"path": "packages/snap/src/snap.ts",
"chars": 8030,
"preview": "import type Lenis from 'lenis'\nimport type { VirtualScrollData } from 'lenis'\nimport { debounce } from './debounce'\nimpo"
},
{
"path": "packages/snap/src/types.ts",
"chars": 1182,
"preview": "import type { EasingFunction } from 'lenis'\n\nexport type SnapItem = {\n value: number\n}\n\nexport type OnSnapCallback = (i"
},
{
"path": "packages/snap/src/uid.ts",
"chars": 89,
"preview": "let index = 0\n\nexport type UID = number\n\nexport function uid(): UID {\n return index++\n}\n"
},
{
"path": "packages/vue/README.md",
"chars": 4240,
"preview": "# lenis/vue\n\n## Introduction\nlenis/vue provides a `<VueLenis>` component that creates a [Lenis](https://github.com/darkr"
},
{
"path": "packages/vue/index.ts",
"chars": 192,
"preview": "// This file serves as an entry point for the package\nexport {\n VueLenis as Lenis,\n VueLenis,\n vueLenisPlugin as defa"
},
{
"path": "packages/vue/nuxt/module.ts",
"chars": 816,
"preview": "import {\n addComponent,\n addImports,\n addPlugin,\n createResolver,\n defineNuxtModule,\n} from '@nuxt/kit'\n\n// Module "
},
{
"path": "packages/vue/nuxt/runtime/lenis.ts",
"chars": 340,
"preview": "import vuePlugin from 'lenis/vue'\nimport type { Plugin } from '#app'\nimport { defineNuxtPlugin } from '#imports'\n\nconst "
},
{
"path": "packages/vue/nuxt/tsconfig.json",
"chars": 187,
"preview": "{\n \"extends\": \"../../../tsconfig.json\",\n \"compilerOptions\": {\n \"baseUrl\": \"./\",\n \"paths\": {\n \"#imports\": [\""
},
{
"path": "packages/vue/nuxt/types/app.d.ts",
"chars": 114,
"preview": "declare module '#app' {\n export interface Plugin {\n name?: string\n setup: (nuxtApp: unknown) => void\n }\n}\n"
},
{
"path": "packages/vue/nuxt/types/imports.d.ts",
"chars": 125,
"preview": "import type { Plugin } from '#app'\n\ndeclare module '#imports' {\n export function defineNuxtPlugin(plugin: Plugin): Plug"
},
{
"path": "packages/vue/package.json",
"chars": 95,
"preview": "{\n \"name\": \"lenis-vue\",\n \"type\": \"module\",\n \"devDependencies\": {\n \"vue\": \"^3.5.13\"\n }\n}\n"
},
{
"path": "packages/vue/src/provider.ts",
"chars": 4293,
"preview": "import Lenis, { type LenisOptions, type ScrollCallback } from 'lenis'\nimport type {\n HTMLAttributes,\n InjectionKey,\n "
},
{
"path": "packages/vue/src/store.ts",
"chars": 345,
"preview": "import type Lenis from 'lenis'\nimport type { ScrollCallback } from 'lenis'\nimport { shallowRef } from 'vue'\n\nexport cons"
},
{
"path": "packages/vue/src/use-lenis.ts",
"chars": 1797,
"preview": "import type Lenis from 'lenis'\nimport type { ScrollCallback } from 'lenis'\nimport { type ComputedRef, computed, inject, "
},
{
"path": "playground/.gitignore",
"chars": 273,
"preview": "# build output\ndist/\n# generated types\n.astro/\n\n# dependencies\nnode_modules/\n\n# logs\nnpm-debug.log*\nyarn-debug.log*\nyarn"
},
{
"path": "playground/astro.config.mjs",
"chars": 711,
"preview": "import path from 'node:path'\nimport react from '@astrojs/react'\nimport vue from '@astrojs/vue'\nimport { defineConfig } f"
},
{
"path": "playground/core/browser.js",
"chars": 174,
"preview": "const lenis = new Lenis()\n\nlenis.on('scroll', (e) => {\n console.log(e)\n})\n\nfunction raf(time) {\n lenis.raf(time)\n req"
},
{
"path": "playground/core/static.html",
"chars": 571,
"preview": "<html lang=\"en\">\r\n <head>\r\n <style>\r\n body {\r\n height: 300vh;\r\n }\r\n </style>\r\n <script src=\"."
},
{
"path": "playground/core/style.css",
"chars": 875,
"preview": "#scroll-to-top {\n position: fixed;\n bottom: 20px;\n right: 20px;\n padding: 10px;\n border: 1px solid #ccc;\n backgrou"
},
{
"path": "playground/core/test.ts",
"chars": 5410,
"preview": "import Lenis from 'lenis'\nimport { LoremIpsum } from 'lorem-ipsum'\n\ndocument.querySelector('#nested-content')!.innerHTML"
},
{
"path": "playground/horizontal/browser.js",
"chars": 174,
"preview": "const lenis = new Lenis()\n\nlenis.on('scroll', (e) => {\n console.log(e)\n})\n\nfunction raf(time) {\n lenis.raf(time)\n req"
},
{
"path": "playground/horizontal/static.html",
"chars": 571,
"preview": "<html lang=\"en\">\r\n <head>\r\n <style>\r\n body {\r\n height: 300vh;\r\n }\r\n </style>\r\n <script src=\"."
},
{
"path": "playground/horizontal/style.css",
"chars": 915,
"preview": "html {\n overflow-y: clip;\n}\n\n#wrapper {\n display: flex;\n height: 100vh;\n align-items: center;\n}\n\n#about,\n#work,\n#wor"
},
{
"path": "playground/horizontal/test.ts",
"chars": 708,
"preview": "import Lenis from 'lenis'\nimport { LoremIpsum } from 'lorem-ipsum'\n\ndocument.querySelector('#work2-content')!.innerHTML "
},
{
"path": "playground/infinite/browser.js",
"chars": 174,
"preview": "const lenis = new Lenis()\n\nlenis.on('scroll', (e) => {\n console.log(e)\n})\n\nfunction raf(time) {\n lenis.raf(time)\n req"
},
{
"path": "playground/infinite/static.html",
"chars": 571,
"preview": "<html lang=\"en\">\r\n <head>\r\n <style>\r\n body {\r\n height: 300vh;\r\n }\r\n </style>\r\n <script src=\"."
},
{
"path": "playground/infinite/style.css",
"chars": 53,
"preview": "html {\n overflow-x: clip;\n}\n\nimg {\n width: 100%;\n}\n"
},
{
"path": "playground/infinite/test.ts",
"chars": 986,
"preview": "import Lenis from 'lenis'\nimport Stats from 'stats-js'\n\nnew Lenis({\n infinite: true,\n autoRaf: true,\n syncTouch: true"
},
{
"path": "playground/nuxt/.gitignore",
"chars": 193,
"preview": "# Nuxt dev/build outputs\n.output\n.data\n.nuxt\n.nitro\n.cache\ndist\n\n# Node dependencies\nnode_modules\n\n# Logs\nlogs\n*.log\n\n# "
},
{
"path": "playground/nuxt/README.md",
"chars": 822,
"preview": "# Nuxt Minimal Starter\n\nLook at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn mo"
},
{
"path": "playground/nuxt/app.vue",
"chars": 955,
"preview": "<script setup lang=\"ts\">\nimport gsap from 'gsap'\nimport { watch, watchEffect } from 'vue'\n\nconst lenis = useLenis((lenis"
},
{
"path": "playground/nuxt/components/inner.vue",
"chars": 271,
"preview": "<script setup>\n// import { useLenis } from 'lenis/vue'\n\n// const lenis = useLenis((lenis) => {\n// console.log('inner c"
},
{
"path": "playground/nuxt/nuxt.config.ts",
"chars": 185,
"preview": "// https://nuxt.com/docs/api/configuration/nuxt-config\nexport default defineNuxtConfig({\n compatibilityDate: '2024-11-0"
},
{
"path": "playground/nuxt/package.json",
"chars": 321,
"preview": "{\n \"name\": \"playground-nuxt\",\n \"type\": \"module\",\n \"scripts\": {\n \"build\": \"nuxt build\",\n \"dev\": \"nuxt dev\",\n "
},
{
"path": "playground/nuxt/pages/about.vue",
"chars": 521,
"preview": "<script setup>\nimport { useLenis } from 'lenis/vue'\n\nconst lenis = useLenis((lenis) => {\n console.log('page callback', "
},
{
"path": "playground/nuxt/pages/index.vue",
"chars": 600,
"preview": "//\n<script setup>\n// import Inner from '../components/inner.vue'\n// import { useLenis } from 'lenis/vue'\n// import { wat"
},
{
"path": "playground/nuxt/plugins/lenis.ts",
"chars": 87,
"preview": "export default defineNuxtPlugin((_nuxtApp) => {\n // nuxtApp.vueApp.use(LenisVue)\n})\n"
},
{
"path": "playground/nuxt/public/robots.txt",
"chars": 1,
"preview": "\n"
},
{
"path": "playground/nuxt/server/tsconfig.json",
"chars": 49,
"preview": "{\n \"extends\": \"../.nuxt/tsconfig.server.json\"\n}\n"
},
{
"path": "playground/nuxt/tsconfig.json",
"chars": 94,
"preview": "{\n // https://nuxt.com/docs/guide/concepts/typescript\n \"extends\": \"./.nuxt/tsconfig.json\"\n}\n"
},
{
"path": "playground/package.json",
"chars": 623,
"preview": "{\n \"name\": \"playground\",\n \"type\": \"module\",\n \"version\": \"0.0.1\",\n \"scripts\": {\n \"dev\": \"astro dev --host\",\n \"s"
},
{
"path": "playground/react/app.tsx",
"chars": 1205,
"preview": "import { type LenisRef, ReactLenis, useLenis } from 'lenis/react'\nimport { LoremIpsum } from 'lorem-ipsum'\nimport { useR"
},
{
"path": "playground/react/style.css",
"chars": 1263,
"preview": "html:not(.lenis) {\n body,\n & {\n margin: 0;\n padding: 0;\n width: 100%;\n height: 100%;\n overflow: hidden;"
},
{
"path": "playground/snap/style.css",
"chars": 1007,
"preview": "/* html {\n scroll-snap-type: y mandatory;\n scroll-behavior: smooth;\n}\n\n.section {\n scroll-snap-align: start;\n} */\n\n/*"
},
{
"path": "playground/snap/test.ts",
"chars": 2134,
"preview": "// import { LoremIpsum } from 'lorem-ipsum'\nimport Lenis from 'lenis'\nimport Snap from 'lenis/snap'\n\n// import Snap from"
},
{
"path": "playground/tsconfig.json",
"chars": 188,
"preview": "{\n \"extends\": \"astro/tsconfigs/strict\",\n \"compilerOptions\": {\n \"jsx\": \"react-jsx\",\n \"jsxImportSource\": \"react\",\n"
},
{
"path": "playground/vue/App.vue",
"chars": 1348,
"preview": "<script setup>\nimport { useLenis } from 'lenis/vue'\nimport { LoremIpsum } from 'lorem-ipsum'\nimport { ref, watch } from "
},
{
"path": "playground/vue/Child.vue",
"chars": 484,
"preview": "<script setup>\nimport { useLenis } from 'lenis/vue'\nimport { watch } from 'vue'\n\nconst lenis = useLenis(\n (lenis) => {\n"
},
{
"path": "playground/vue/InnerChild.vue",
"chars": 308,
"preview": "<script setup>\nimport { useLenis } from 'lenis/vue'\nimport { LoremIpsum } from 'lorem-ipsum'\n\nuseLenis((lenis) => {\n co"
},
{
"path": "playground/vue/setup.ts",
"chars": 118,
"preview": "import LenisVue from 'lenis/vue'\nimport type { App } from 'vue'\n\nexport default (app: App) => {\n app.use(LenisVue)\n}\n"
},
{
"path": "playground/vue/style.css",
"chars": 0,
"preview": ""
},
{
"path": "playground/www/layouts/Layout.astro",
"chars": 2296,
"preview": "---\r\nimport 'lenis/dist/lenis.css'\ninterface Props {\n title: string\n}\n\nconst { title } = Astro.props\n---\r\n\r\n<html lang="
},
{
"path": "playground/www/pages/core.astro",
"chars": 2367,
"preview": "---\nimport '~/core/style.css'\nimport Layout from '../layouts/Layout.astro'\n---\n\n<Layout title=\"Lenis\">\n <div id=\"debug\""
},
{
"path": "playground/www/pages/horizontal.astro",
"chars": 1790,
"preview": "---\nimport '~/horizontal/style.css'\nimport Layout from '../layouts/Layout.astro'\n---\n\n<Layout title=\"Lenis\">\n <div id=\""
},
{
"path": "playground/www/pages/index.astro",
"chars": 963,
"preview": "---\nimport Layout from '../layouts/Layout.astro'\n---\n\n<Layout title=\"Lenis Playground\">\n <main>\n <h1>Welcome to the "
},
{
"path": "playground/www/pages/infinite.astro",
"chars": 4129,
"preview": "---\nimport '~/infinite/style.css'\nimport Layout from '../layouts/Layout.astro'\n---\n\n<Layout title=\"Lenis\">\n <div id=\"wr"
},
{
"path": "playground/www/pages/react.astro",
"chars": 180,
"preview": "---\nimport App from '~/react/app'\nimport '~/react/style.css'\nimport Layout from '../layouts/Layout.astro'\n---\n<Layout ti"
},
{
"path": "playground/www/pages/snap.astro",
"chars": 791,
"preview": "---\nimport '~/snap/style.css'\nimport Layout from '../layouts/Layout.astro'\n---\n\n<Layout title=\"Lenis Snap\">\n <div id=\"w"
},
{
"path": "playground/www/pages/vue.astro",
"chars": 176,
"preview": "---\nimport App from '~/vue/App.vue'\nimport '~/vue/style.css'\nimport Layout from '../layouts/Layout.astro'\n---\n<Layout ti"
},
{
"path": "scripts/update-readme.js",
"chars": 743,
"preview": "import fs from 'node:fs'\nimport packageJson from '../package.json' with { type: 'json' }\n\nconst readmePath = './README.m"
},
{
"path": "tsconfig.json",
"chars": 706,
"preview": "{\n \"exclude\": [\"dist\", \"playground\", \"website\"],\n \"compilerOptions\": {\n /* Base Options: */\n \"skipLibCheck\": tru"
},
{
"path": "tsdown.config.ts",
"chars": 1921,
"preview": "import { defineConfig } from 'tsdown'\n\nconst shared = {\n outDir: 'dist',\n target: 'es2022' as const,\n platform: 'brow"
}
]
About this extraction
This page contains the full source code of the darkroomengineering/lenis GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 100 files (176.8 KB), approximately 47.2k tokens, and a symbol index with 137 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.