├── .babelrc.json ├── .eslintrc.json ├── .gitignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── config ├── paths.js ├── webpack.common.js ├── webpack.dev.js └── webpack.prod.js ├── jsconfig.json ├── package-lock.json ├── package.json ├── postcss.config.js ├── public └── example.png └── src ├── fonts └── Inter.ttf ├── images ├── favicon.png └── webpack-logo.svg ├── index.js ├── js └── scroll-snap.js ├── styles ├── _scaffolding.scss ├── _variables.scss └── index.scss └── template.html /.babelrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"], 3 | "plugins": ["@babel/plugin-proposal-class-properties"] 4 | } 5 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "prefer-template": "off", 4 | "no-var": 1, 5 | "no-unused-vars": 1, 6 | "camelcase": 1, 7 | "no-nested-ternary": 1, 8 | "no-console": 1, 9 | "no-template-curly-in-string": 1, 10 | "no-self-compare": 1, 11 | "import/prefer-default-export": 0, 12 | "arrow-body-style": 1, 13 | "import/no-extraneous-dependencies": ["off", { "devDependencies": false }] 14 | }, 15 | "ignorePatterns": ["dist", "node_modules", "webpack.*", "config/paths.js"], 16 | "env": { 17 | "browser": true, 18 | "es6": true 19 | }, 20 | "extends": ["eslint:recommended", "prettier"], 21 | "parserOptions": { 22 | "ecmaVersion": 2021, 23 | "sourceType": "module" 24 | }, 25 | "plugins": ["prettier"], 26 | "settings": { 27 | "import/resolver": { 28 | "webpack": { 29 | "config": "config/webpack.common.js" 30 | } 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/.DS_Store 2 | /node_modules 3 | /dist 4 | /src/js/scroll-snap.ts -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "singleQuote": true, 4 | "tabWidth": 2, 5 | "printWidth": 100, 6 | "semi": false 7 | } 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Tania Rascia 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 📦 Lenis Scroll Snap Plugin 2 | This is a [Lenis](https://github.com/studio-freight/lenis/) plugin to mimic scroll-snap css property. 3 | 4 | ## How to use 5 | ```` 6 | const lenis = new Lenis({ 7 | direction:'horizontal', 8 | wrapper: document.getElementById('wrapper'), 9 | content: document.getElementById('root'), 10 | }); 11 | 12 | const config = {{snapType: 'mandatory'}} 13 | new ScrollSnap(lenis, config) 14 | ```` 15 | 16 | ### Available wrapper attributes 17 | - `scroll-snap-type` ( mandatory | proximity ) 18 | Default value: **mandatory** 19 | 20 | ### Available element attributes 21 | - `scroll-snap-align` ( start | center | end | none ) 22 | Default value: **start** 23 | ### Config 24 | - `snapType` ( mandatory | proximity ) 25 | This config setting overrides `scroll-snap-type` wrapper attribute value. -------------------------------------------------------------------------------- /config/paths.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | module.exports = { 4 | // Source files 5 | src: path.resolve(__dirname, '../src'), 6 | 7 | // Production build files 8 | build: path.resolve(__dirname, '../dist'), 9 | 10 | // Static files that get copied to build folder 11 | public: path.resolve(__dirname, '../public'), 12 | } 13 | -------------------------------------------------------------------------------- /config/webpack.common.js: -------------------------------------------------------------------------------- 1 | const { CleanWebpackPlugin } = require('clean-webpack-plugin') 2 | const CopyWebpackPlugin = require('copy-webpack-plugin') 3 | const HtmlWebpackPlugin = require('html-webpack-plugin') 4 | 5 | const paths = require('./paths') 6 | 7 | module.exports = { 8 | // Where webpack looks to start building the bundle 9 | entry: [paths.src + '/index.js'], 10 | 11 | // Where webpack outputs the assets and bundles 12 | output: { 13 | path: paths.build, 14 | filename: '[name].bundle.js', 15 | publicPath: '/', 16 | }, 17 | 18 | // Customize the webpack build process 19 | plugins: [ 20 | // Removes/cleans build folders and unused assets when rebuilding 21 | new CleanWebpackPlugin(), 22 | 23 | // Copies files from target to destination folder 24 | new CopyWebpackPlugin({ 25 | patterns: [ 26 | { 27 | from: paths.public, 28 | to: 'assets', 29 | globOptions: { 30 | ignore: ['*.DS_Store'], 31 | }, 32 | noErrorOnMissing: true, 33 | }, 34 | ], 35 | }), 36 | 37 | // Generates an HTML file from a template 38 | // Generates deprecation warning: https://github.com/jantimon/html-webpack-plugin/issues/1501 39 | new HtmlWebpackPlugin({ 40 | title: 'Lenis Scroll Snap Plugin', 41 | favicon: paths.src + '/images/favicon.png', 42 | template: paths.src + '/template.html', // template file 43 | filename: 'index.html', // output file 44 | }), 45 | ], 46 | 47 | // Determine how modules within the project are treated 48 | module: { 49 | rules: [ 50 | // JavaScript: Use Babel to transpile JavaScript files 51 | { test: /\.js$/, use: ['babel-loader'] }, 52 | 53 | // Images: Copy image files to build folder 54 | { test: /\.(?:ico|gif|png|jpg|jpeg)$/i, type: 'asset/resource' }, 55 | 56 | // Fonts and SVGs: Inline files 57 | { test: /\.(woff(2)?|eot|ttf|otf|svg|)$/, type: 'asset/inline' }, 58 | ], 59 | }, 60 | 61 | resolve: { 62 | modules: [paths.src, 'node_modules'], 63 | extensions: ['.js', '.jsx', '.json'], 64 | alias: { 65 | '@': paths.src, 66 | assets: paths.public, 67 | }, 68 | }, 69 | } 70 | -------------------------------------------------------------------------------- /config/webpack.dev.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge') 2 | 3 | const common = require('./webpack.common') 4 | 5 | module.exports = merge(common, { 6 | // Set the mode to development or production 7 | mode: 'development', 8 | 9 | // Control how source maps are generated 10 | devtool: 'inline-source-map', 11 | 12 | // Spin up a server for quick development 13 | devServer: { 14 | historyApiFallback: true, 15 | open: true, 16 | compress: true, 17 | hot: true, 18 | port: 8080, 19 | }, 20 | 21 | module: { 22 | rules: [ 23 | // Styles: Inject CSS into the head with source maps 24 | { 25 | test: /\.(sass|scss|css)$/, 26 | use: [ 27 | 'style-loader', 28 | { 29 | loader: 'css-loader', 30 | options: { sourceMap: true, importLoaders: 1, modules: false }, 31 | }, 32 | { loader: 'postcss-loader', options: { sourceMap: true } }, 33 | { loader: 'sass-loader', options: { sourceMap: true } }, 34 | ], 35 | }, 36 | ], 37 | }, 38 | }) 39 | -------------------------------------------------------------------------------- /config/webpack.prod.js: -------------------------------------------------------------------------------- 1 | const MiniCssExtractPlugin = require('mini-css-extract-plugin') 2 | const CssMinimizerPlugin = require('css-minimizer-webpack-plugin') 3 | const { merge } = require('webpack-merge') 4 | 5 | const paths = require('./paths') 6 | const common = require('./webpack.common') 7 | 8 | module.exports = merge(common, { 9 | mode: 'production', 10 | devtool: false, 11 | output: { 12 | path: paths.build, 13 | publicPath: '/', 14 | filename: 'js/[name].[contenthash].bundle.js', 15 | }, 16 | module: { 17 | rules: [ 18 | { 19 | test: /\.(sass|scss|css)$/, 20 | use: [ 21 | MiniCssExtractPlugin.loader, 22 | { 23 | loader: 'css-loader', 24 | options: { 25 | importLoaders: 2, 26 | sourceMap: false, 27 | modules: false, 28 | }, 29 | }, 30 | 'postcss-loader', 31 | 'sass-loader', 32 | ], 33 | }, 34 | ], 35 | }, 36 | plugins: [ 37 | // Extracts CSS into separate files 38 | new MiniCssExtractPlugin({ 39 | filename: 'styles/[name].[contenthash].css', 40 | chunkFilename: '[id].css', 41 | }), 42 | ], 43 | optimization: { 44 | minimize: true, 45 | minimizer: [new CssMinimizerPlugin(), '...'], 46 | runtimeChunk: { 47 | name: 'runtime', 48 | }, 49 | }, 50 | performance: { 51 | hints: false, 52 | maxEntrypointSize: 512000, 53 | maxAssetSize: 512000, 54 | }, 55 | }) 56 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "paths": { 5 | "@/*": ["./src/*"] 6 | }, 7 | "allowJs": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lenis-scroll-snap", 3 | "version": "0.0.1", 4 | "description": "Lenis scroll snap plugin", 5 | "main": "index.js", 6 | "author": "Chengmin Li", 7 | "license": "MIT", 8 | "scripts": { 9 | "start": "cross-env NODE_ENV=development webpack serve --config config/webpack.dev.js", 10 | "build": "cross-env NODE_ENV=production webpack --config config/webpack.prod.js", 11 | "lint": "eslint 'src/**/*.js' || true", 12 | "prettify": "prettier --write 'src/**/*.js'" 13 | }, 14 | "keywords": [ 15 | "lenis", 16 | "scroll-snap" 17 | ], 18 | "repository": { 19 | "type": "git", 20 | "url": "git@github.com:rsm0128/lenis-scroll-snap" 21 | }, 22 | "devDependencies": { 23 | "@babel/core": "^7.20.7", 24 | "@babel/plugin-proposal-class-properties": "^7.18.6", 25 | "@babel/preset-env": "^7.20.2", 26 | "babel-loader": "^9.1.0", 27 | "clean-webpack-plugin": "^4.0.0", 28 | "copy-webpack-plugin": "^11.0.0", 29 | "cross-env": "^7.0.3", 30 | "css-loader": "^6.7.3", 31 | "css-minimizer-webpack-plugin": "^4.2.2", 32 | "eslint": "^8.31.0", 33 | "eslint-config-prettier": "^8.6.0", 34 | "eslint-import-resolver-webpack": "^0.13.2", 35 | "eslint-plugin-prettier": "^4.2.1", 36 | "html-webpack-plugin": "^5.5.0", 37 | "mini-css-extract-plugin": "^2.7.2", 38 | "postcss-loader": "^7.0.2", 39 | "postcss-preset-env": "^7.8.3", 40 | "prettier": "^2.8.1", 41 | "sass": "^1.57.1", 42 | "sass-loader": "^13.2.0", 43 | "style-loader": "^3.3.1", 44 | "webpack": "^5.75.0", 45 | "webpack-cli": "^5.0.1", 46 | "webpack-dev-server": "^4.11.1", 47 | "webpack-merge": "^5.8.0" 48 | }, 49 | "dependencies": { 50 | "@studio-freight/lenis": "^0.2.28" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | 'postcss-preset-env': { 4 | browsers: 'last 2 versions', 5 | }, 6 | }, 7 | } 8 | -------------------------------------------------------------------------------- /public/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/funkhaus/lenis-scroll-snap/4827cdc62e8b42ed55643926a791349f9ce4a9fd/public/example.png -------------------------------------------------------------------------------- /src/fonts/Inter.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/funkhaus/lenis-scroll-snap/4827cdc62e8b42ed55643926a791349f9ce4a9fd/src/fonts/Inter.ttf -------------------------------------------------------------------------------- /src/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/funkhaus/lenis-scroll-snap/4827cdc62e8b42ed55643926a791349f9ce4a9fd/src/images/favicon.png -------------------------------------------------------------------------------- /src/images/webpack-logo.svg: -------------------------------------------------------------------------------- 1 | logo-on-dark-bg -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // Test import of styles 2 | import '@/styles/index.scss' 3 | 4 | import Lenis from '@studio-freight/lenis' 5 | import {ScrollSnap} from '@/js/scroll-snap' 6 | 7 | const lenis = new Lenis({ 8 | direction:'horizontal', 9 | wrapper: document.getElementById('wrapper'), 10 | content: document.getElementById('root'), 11 | }); 12 | 13 | new ScrollSnap(lenis, {snapType: ''}) 14 | 15 | const lenisMandatory = new Lenis({ 16 | direction:'vertical', 17 | wrapper: document.getElementById('wrapper-mandatory'), 18 | content: document.getElementById('root-mandatory'), 19 | }); 20 | 21 | new ScrollSnap(lenisMandatory, {snapType: ''}) 22 | 23 | function raf(time) { 24 | lenis.raf(time) 25 | lenisMandatory.raf(time) 26 | requestAnimationFrame(raf) 27 | } 28 | 29 | requestAnimationFrame(raf) 30 | window.lenis = lenis -------------------------------------------------------------------------------- /src/js/scroll-snap.js: -------------------------------------------------------------------------------- 1 | // https://blog.logrocket.com/style-scroll-snap-points-css/ 2 | // https://codepen.io/alsacreations/pen/GaebzJ 3 | // snap/onSnapComplete https://greensock.com/docs/v3/Plugins/ScrollTrigger 4 | export class ScrollSnap { 5 | constructor(lenis, { snapType }) { 6 | this.lenis = lenis 7 | this.isHorizontal = this.lenis.direction === 'horizontal' // we can set different value in case we need snap for different axis. 8 | this.rootElement = this.lenis.wrapperNode === window ? this.lenis.contentNode : this.lenis.wrapperNode 9 | this.snapType = snapType || this.rootElement.getAttribute('scroll-snap-type') || 'mandatory' 10 | 11 | this.initElements() 12 | lenis.on('scroll', this.onScroll) 13 | } 14 | 15 | initElements() { 16 | this.elements = Array.from( 17 | this.rootElement.querySelectorAll( 18 | '[scroll-snap-align]:not([scroll-snap-align="none"]' 19 | ) 20 | ).map((element) => { 21 | let snapAlign = element.getAttribute('scroll-snap-align') 22 | if (!['start', 'center', 'end', 'none'].includes(snapAlign)) { 23 | snapAlign = 'start' // default value: start 24 | } 25 | 26 | // let snapStop = element.getAttribute('scroll-snap-stop') 27 | // if (!['normal', 'always'].includes(snapAlign)) { 28 | // snapStop = 'normal' // default value: start 29 | // } 30 | 31 | return { 32 | element, 33 | snapAlign, 34 | // snapStop, 35 | } 36 | }) 37 | } 38 | 39 | onScroll = ({ velocity }) => { 40 | if (Math.abs(velocity) > 0.1) return 41 | 42 | const wrapperRect = 43 | this.lenis.wrapperNode === window 44 | ? { 45 | left: 0, 46 | top: 0, 47 | width: this.lenis.wrapperWidth, 48 | height: this.lenis.wrapperHeight, 49 | } 50 | : this.lenis.wrapperNode.getBoundingClientRect() 51 | 52 | const wrapperPos = this.isHorizontal ? wrapperRect.left : wrapperRect.top 53 | 54 | // find the closest element according to the scroll position 55 | const elements = this.elements 56 | .map(({ element, snapAlign }) => { 57 | const elRect = element.getBoundingClientRect() 58 | 59 | let offset = 0 60 | if ('end' === snapAlign) { 61 | offset = this.isHorizontal 62 | ? elRect.width - wrapperRect.width 63 | : elRect.height - wrapperRect.height 64 | } else if ('center' === snapAlign) { 65 | offset = this.isHorizontal 66 | ? (elRect.width - wrapperRect.width) / 2 67 | : (elRect.height - wrapperRect.height) / 2 68 | } 69 | 70 | const elPos = this.isHorizontal ? elRect.left : elRect.top 71 | 72 | const distance = Math.abs(elPos - wrapperPos + offset) 73 | return { element, distance, elRect, offset } 74 | }) 75 | .sort((a, b) => a.distance - b.distance) 76 | 77 | let limit = this.isHorizontal ? wrapperRect.width : wrapperRect.height 78 | if ( 'proximity' === this.snapType ) { 79 | limit *= 0.3 // proximity is 30% 80 | } 81 | 82 | const element = elements[0] 83 | if (element.distance >= limit) { 84 | return 85 | } 86 | 87 | this.lenis.scrollTo(element.element, { offset: element.offset }) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/styles/_scaffolding.scss: -------------------------------------------------------------------------------- 1 | html { 2 | font-size: $font-size; 3 | font-family: $font-family; 4 | background: $background-color; 5 | color: $font-color; 6 | line-height: 1.4; 7 | } 8 | 9 | body { 10 | margin: auto; 11 | text-align: center; 12 | max-width: $page-width; 13 | } 14 | 15 | h1 { 16 | margin: 0 0 2rem; 17 | } 18 | 19 | .image { 20 | display: inline-block; 21 | height: 100px; 22 | width: 100px; 23 | background-image: url('~assets/example.png'); 24 | background-size: cover; 25 | } 26 | -------------------------------------------------------------------------------- /src/styles/_variables.scss: -------------------------------------------------------------------------------- 1 | $font-size: 1rem; 2 | $font-family: 'Inter', sans-serif; 3 | $background-color: #121212; 4 | $font-color: #dae0e0; 5 | $page-width: 650px; 6 | -------------------------------------------------------------------------------- /src/styles/index.scss: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Inter'; 3 | src: url('../fonts/Inter.ttf'); 4 | } 5 | 6 | @import 'variables'; 7 | @import 'scaffolding'; 8 | 9 | section { 10 | display: flex; 11 | justify-content: center; 12 | align-items: center; 13 | flex-shrink: 0; 14 | height: 500px; 15 | color: #000; 16 | font-size: 24px; 17 | } 18 | #root { 19 | display: flex; 20 | width: fit-content; 21 | } 22 | #wrapper { 23 | overflow-x: auto; 24 | section { 25 | width: 500px; 26 | } 27 | } 28 | 29 | #wrapper-mandatory { 30 | overflow-y: auto; 31 | height: 600px; 32 | } 33 | 34 | #root-mandatory { 35 | height: fit-content; 36 | } -------------------------------------------------------------------------------- /src/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 | 12 |

Horizontal Proximity

13 |
14 |
15 |
Align Start 1
16 |
Align Center
17 |
Align End
18 |
Align None
19 |
Align Start 2
20 |
Align Start 3
21 |
22 |
23 | 24 |

Vertical Mandatory

25 |
26 |
27 |
Align Start 1
28 |
Align Center
29 |
Align End
30 |
Align None
31 |
Align Start 2
32 |
Align Start 3
33 |
34 |
35 | 36 | 37 | --------------------------------------------------------------------------------