├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .gitattributes ├── .gitignore ├── .nuxtrc ├── CHANGELOG.md ├── README.md ├── package.json ├── playground ├── app.vue ├── components │ └── BaseChild.vue ├── nuxt.config.ts ├── package.json └── pages │ ├── about.vue │ └── index.vue ├── src ├── module.ts └── runtime │ ├── components │ └── lenis.vue │ ├── composables │ └── useLenis.ts │ └── plugin.ts ├── tsconfig.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_size = 3 5 | indent_style = space 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@nuxtjs/eslint-config-typescript" 4 | ], 5 | "rules": { 6 | "@typescript-eslint/no-unused-vars": [ 7 | "off" 8 | ] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | 4 | # Logs 5 | *.log* 6 | 7 | # Temp directories 8 | .temp 9 | .tmp 10 | .cache 11 | 12 | # Yarn 13 | **/.yarn/cache 14 | **/.yarn/*state* 15 | 16 | # Generated dirs 17 | dist 18 | 19 | # Nuxt 20 | .nuxt 21 | .output 22 | .vercel_build_output 23 | .build-* 24 | .env 25 | .netlify 26 | 27 | # Env 28 | .env 29 | 30 | # Testing 31 | reports 32 | coverage 33 | *.lcov 34 | .nyc_output 35 | 36 | # VSCode 37 | .vscode/* 38 | !.vscode/settings.json 39 | !.vscode/tasks.json 40 | !.vscode/launch.json 41 | !.vscode/extensions.json 42 | !.vscode/*.code-snippets 43 | 44 | # Intellij idea 45 | *.iml 46 | .idea 47 | 48 | # OSX 49 | .DS_Store 50 | .AppleDouble 51 | .LSOverride 52 | .AppleDB 53 | .AppleDesktop 54 | Network Trash Folder 55 | Temporary Items 56 | .apdisk 57 | -------------------------------------------------------------------------------- /.nuxtrc: -------------------------------------------------------------------------------- 1 | imports.autoImport=false 2 | typescript.includeWorkspace=true -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### v1.2.9 2 | - Fixed issue where watcher was not triggering 3 | - Made it cleaner for multiple instances. Removed need for `.value` 4 | 5 | ### v1.2.8 6 | - Updated the composable structure to fix the class issue caused by useState and private attributes 7 | - updated Lenis to the latest version 8 | - added a helper to get a specific Lenis instance `lenisInstance` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nuxt Lenis 2 | 3 | This is a Nuxt wrapper for [Lenis](https://lenis.studiofreight.com/) (by [Studio Freight](https://studiofreight.com/)) – providing smooth scrolling with support for multiple instances in a type-safe and reactive way. 4 | 5 | > **Version 2 Notice:** 6 | > In version 2, the module has been refactored to centrally manage multiple Lenis instances via a Nuxt plugin. Use the provided composable (`useLenis`) for accessing instance methods and reactive scroll state. The component expects an `onScroll` prop for scroll callbacks (instead of using emits). 7 | 8 | --- 9 | 10 | ## Getting Started 11 | 12 | 1. **Install the package:** 13 | 14 | ```bash 15 | yarn add nuxt-lenis 16 | ``` 17 | 18 | 2. **Add the module in your `nuxt.config`:** 19 | 20 | ```js 21 | export default { 22 | modules: [ 23 | 'nuxt-lenis' 24 | ] 25 | } 26 | ``` 27 | 28 | 3. **Wrap your page with the Lenis component in `app.vue`:** 29 | 30 | ```vue 31 | 39 | 40 | 45 | ``` 46 | 47 | --- 48 | 49 | ## Component Usage 50 | 51 | The `` component (located at `src/runtime/components/Lenis.vue`) accepts the following props: 52 | 53 | - **id** (String, default: `"default"`): A unique identifier for your Lenis instance. 54 | - **root** (Boolean, default: `true`): Determines if the window should be used as the scroll container. 55 | - **options** (Object): Lenis options (e.g., `duration`, `autoRaf`, `direction`). 56 | - **onScroll** (Function): Callback fired whenever a scrolling event is received. 57 | 58 | Example: 59 | 60 | ```vue 61 | 66 | 67 | 79 | ``` 80 | 81 | --- 82 | 83 | ## Composable Usage 84 | 85 | Access the reactive Lenis API via the composable. By default, `useLenis()` returns the following properties: 86 | 87 | - **createLenis**: Function to create and register a Lenis instance. 88 | - **getLenis**: Function that returns the Lenis instance (or `null` if not found). 89 | - **destroyLenis**: Function to destroy an instance. 90 | - **scrollState**: Function to access the reactive scroll state of an instance. 91 | - **watchScrollState**: Function to watch changes to the scroll state. 92 | 93 | ### Single Instance Usage 94 | 95 | For many cases, a single instance is sufficient: 96 | 97 | ```vue 98 | 112 | ``` 113 | 114 | ### Multiple Instances 115 | 116 | If you need multiple instances, pass `false` (or use different IDs on each `` component) and access them by ID: 117 | 118 | ```vue 119 | 127 | 128 | 142 | ``` 143 | 144 | --- 145 | 146 | ## Plugin API 147 | 148 | The Nuxt plugin (located at `src/runtime/plugin.ts`) exposes the following methods: 149 | 150 | - **createLenis(id, options?)** 151 | Creates a new Lenis instance for a given ID and registers it. 152 | 153 | - **getLenis(id?)** 154 | Retrieves a Lenis instance by its ID (or the default instance if none is specified). 155 | 156 | - **destroyLenis(id)** 157 | Destroys the specified Lenis instance and cleans up its associated state. 158 | 159 | - **getScrollState(id?)** 160 | Returns the current scroll state for the specified instance, which is updated reactively. 161 | 162 | The composable (`useLenis`) wraps these methods and provides a streamlined API for your components. 163 | 164 | --- 165 | 166 | Happy scrolling! -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nuxt-lenis", 3 | "version": "2.1.2", 4 | "license": "MIT", 5 | "type": "module", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/MilkshakeStudio/nuxt-lenis.git" 9 | }, 10 | "keywords": [ 11 | "nuxt", 12 | "module", 13 | "nuxt-module", 14 | "lenis" 15 | ], 16 | "author": { 17 | "name": "Milkshake Studio ", 18 | "url": "https://milkshake.studio" 19 | }, 20 | "exports": { 21 | ".": { 22 | "types": "./dist/types.d.ts", 23 | "import": "./dist/module.mjs", 24 | "require": "./dist/module.cjs" 25 | } 26 | }, 27 | "main": "./dist/module.cjs", 28 | "types": "./dist/types.d.ts", 29 | "files": [ 30 | "dist" 31 | ], 32 | "scripts": { 33 | "prepack": "nuxt-module-build", 34 | "dev": "nuxi dev playground", 35 | "dev:build": "nuxi build playground", 36 | "dev:prepare": "nuxt-module-build --stub && nuxi prepare playground", 37 | "release": "npm run lint && npm run test && npm run prepack && changelogen --release && npm publish && git push --follow-tags", 38 | "lint": "eslint .", 39 | "test": "vitest run", 40 | "test:watch": "vitest watch" 41 | }, 42 | "dependencies": { 43 | "lenis": "^1.2.3" 44 | }, 45 | "devDependencies": { 46 | "@nuxt/devtools": "^0.8.5", 47 | "@nuxt/eslint-config": "^0.1.1", 48 | "@nuxt/kit": "^3.6.5", 49 | "@nuxt/module-builder": "^0.4.0", 50 | "@nuxt/schema": "^3.6.5", 51 | "@nuxt/test-utils": "^3.6.5", 52 | "@nuxtjs/eslint-config-typescript": "^12.0.0", 53 | "@types/node": "^18.17.1", 54 | "changelogen": "^0.5.4", 55 | "eslint": "^8.46.0", 56 | "nuxt": "^3.6.5", 57 | "vitest": "^0.33.0" 58 | }, 59 | "resolutions": { 60 | "string-width": "4.2.3" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /playground/app.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 21 | 22 | 36 | -------------------------------------------------------------------------------- /playground/components/BaseChild.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 19 | 20 | 28 | -------------------------------------------------------------------------------- /playground/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | import { defineNuxtConfig } from 'nuxt/config' 2 | import MyModule from '..' 3 | 4 | export default defineNuxtConfig({ 5 | modules: [MyModule], 6 | 7 | myModule: { 8 | addPlugin: true 9 | }, 10 | 11 | compatibilityDate: '2025-02-08' 12 | }) 13 | -------------------------------------------------------------------------------- /playground/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "my-module-playground" 4 | } 5 | -------------------------------------------------------------------------------- /playground/pages/about.vue: -------------------------------------------------------------------------------- 1 | 3 | 4 | 12 | 13 | 15 | -------------------------------------------------------------------------------- /playground/pages/index.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 68 | 69 | 103 | -------------------------------------------------------------------------------- /src/module.ts: -------------------------------------------------------------------------------- 1 | import { 2 | addComponent, 3 | defineNuxtModule, 4 | addPlugin, 5 | createResolver, 6 | addImports 7 | } from '@nuxt/kit' 8 | 9 | export interface ModuleOptions { 10 | addPlugin: boolean; 11 | } 12 | 13 | export default defineNuxtModule({ 14 | meta: { 15 | name: '@milkshake/nuxt-lenis', 16 | configKey: 'lenis', 17 | compatibility: { 18 | nuxt: '^3.0.0' 19 | } 20 | }, 21 | defaults: { 22 | addPlugin: true 23 | }, 24 | setup (options, nuxt) { 25 | const { resolve } = createResolver(import.meta.url) 26 | 27 | addImports([ 28 | { 29 | name: 'default', 30 | as: 'Lenis', 31 | from: 'lenis' 32 | }, 33 | { 34 | name: 'useLenis', 35 | as: 'useLenis', 36 | from: resolve('./runtime/composables/useLenis') 37 | } 38 | ]) 39 | 40 | addPlugin(resolve('./runtime/plugin')) 41 | 42 | addComponent({ 43 | name: 'Lenis', // name of the component to be used in vue templates 44 | filePath: resolve('./runtime/components', 'Lenis.vue') 45 | }) 46 | } 47 | }) 48 | -------------------------------------------------------------------------------- /src/runtime/components/lenis.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 60 | 61 | 75 | -------------------------------------------------------------------------------- /src/runtime/composables/useLenis.ts: -------------------------------------------------------------------------------- 1 | import { useNuxtApp } from '#app' 2 | import { watch } from 'vue' 3 | import type { LenisPlugin } from '../plugin' 4 | 5 | export function useLenis () { 6 | const lenis = useNuxtApp().$lenis as LenisPlugin 7 | 8 | if (!lenis) { 9 | throw new Error('[Lenis] Lenis is not provided.') 10 | } 11 | 12 | const watchScrollState = (callback: (state: any) => void, id?: string) => { 13 | watch( 14 | () => lenis.getScrollState(id), 15 | (state) => { 16 | if (state) { callback(state) } 17 | }, 18 | { deep: true, immediate: true } 19 | ) 20 | } 21 | 22 | return { 23 | createLenis: lenis.createLenis, 24 | getLenis: lenis.getLenis, 25 | destroyLenis: lenis.destroyLenis, 26 | scrollState: lenis.getScrollState, 27 | watchScrollState 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/runtime/plugin.ts: -------------------------------------------------------------------------------- 1 | import { defineNuxtPlugin } from '#app' 2 | import { reactive, ref } from 'vue' 3 | import type { LenisOptions, ScrollCallback, Scrolling } from 'lenis' 4 | import { Lenis } from '#imports' 5 | // TODO: move to another file 6 | export type ScrollState = { 7 | scroll: number; 8 | animatedScroll: number; 9 | velocity: number; 10 | limit: number; 11 | progress: number; 12 | isScrolling: Scrolling; 13 | isStopped: boolean; 14 | isTouching?: boolean; 15 | isHorizontal: boolean; 16 | isLocked: boolean; 17 | isSmooth: boolean; 18 | rootElement: HTMLElement | null; 19 | direction: 1 | -1 | 0; 20 | lastVelocity: number; 21 | targetScroll: number; 22 | }; 23 | export interface LenisPlugin { 24 | createLenis: (id: string, options?: LenisOptions) => Lenis; 25 | getLenis: (id?: string) => Lenis | null; 26 | destroyLenis: (id: string) => void; 27 | getScrollState: (id?: string) => ScrollState | null; 28 | } 29 | 30 | export default defineNuxtPlugin((nuxtApp) => { 31 | // Centralized storage for Lenis instances and their scroll states 32 | const instances = reactive(new Map()) 33 | const scrollStates = reactive(new Map()) 34 | const defaultInstance = ref(null) 35 | 36 | const createLenis = (id: string, options: LenisOptions = {}) => { 37 | if (instances.has(id)) { 38 | console.warn(`[Lenis] Instance with ID "${id}" already exists.`) 39 | return instances.get(id) 40 | } 41 | 42 | const lenis = new Lenis(options) 43 | // CUSTOM RAF 44 | // if (!options.autoRaf) { 45 | // const raf = (time) => { 46 | // lenis.raf(time); 47 | // requestAnimationFrame(raf); 48 | // }; 49 | // requestAnimationFrame(raf); 50 | // } 51 | instances.set(id, lenis) 52 | 53 | // Initialize scroll state 54 | scrollStates.set(id, { 55 | scroll: 0, 56 | animatedScroll: 0, 57 | velocity: 0, 58 | progress: 0, 59 | limit: 0, 60 | isScrolling: false, 61 | isStopped: true, 62 | isTouching: false, 63 | isHorizontal: false, 64 | isLocked: false, 65 | isSmooth: false, 66 | rootElement: null, 67 | direction: 1, 68 | lastVelocity: 0, 69 | targetScroll: 0 70 | }) 71 | 72 | // Update scroll state on scroll 73 | lenis.on('scroll', (scrollData: Lenis) => { 74 | scrollStates.set(id, { 75 | limit: scrollData.limit, 76 | animatedScroll: scrollData.animatedScroll, 77 | scroll: scrollData.scroll, 78 | velocity: scrollData.velocity, 79 | progress: scrollData.progress, 80 | isScrolling: scrollData.isScrolling, 81 | isStopped: scrollData.isStopped, 82 | isTouching: scrollData.isTouching, 83 | isHorizontal: scrollData.isHorizontal, 84 | isLocked: scrollData.isLocked, 85 | isSmooth: scrollData.isSmooth, 86 | rootElement: scrollData.rootElement, 87 | direction: scrollData.direction, 88 | lastVelocity: scrollData.lastVelocity, 89 | targetScroll: scrollData.targetScroll 90 | }) 91 | }) 92 | 93 | // Automatically set as default instance 94 | if (!defaultInstance.value) { 95 | defaultInstance.value = id 96 | } 97 | 98 | return lenis 99 | } 100 | 101 | const getLenis = (id?: string) => { 102 | const targetId = id || defaultInstance.value 103 | if (!targetId || !instances.has(targetId)) { 104 | console.warn(`[Lenis] No instance found for ID "${targetId}".`) 105 | return null 106 | } 107 | 108 | return instances.get(targetId) 109 | } 110 | 111 | const destroyLenis = (id: string) => { 112 | if (!instances.has(id)) { 113 | console.warn(`[Lenis] No instance found for ID "${id}".`) 114 | return 115 | } 116 | instances.get(id)?.destroy() 117 | instances.delete(id) 118 | scrollStates.delete(id) 119 | 120 | if (defaultInstance.value === id) { 121 | defaultInstance.value = 122 | instances.size > 0 ? Array.from(instances.keys())[0] : null 123 | } 124 | } 125 | 126 | const getScrollState = (id?: string): ScrollState | null => { 127 | const targetId = id || defaultInstance.value 128 | return scrollStates.get(targetId) || null 129 | } 130 | 131 | nuxtApp.provide('lenis', { 132 | createLenis, 133 | getLenis, 134 | destroyLenis, 135 | getScrollState 136 | }) 137 | }) 138 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./playground/.nuxt/tsconfig.json" 3 | } 4 | --------------------------------------------------------------------------------