├── .github └── workflows │ ├── coverage.yml │ ├── main.yml │ └── size.yml ├── .gitignore ├── .np-config.json ├── .npmignore ├── LICENSE ├── README.md ├── docs └── TSDX_README.md ├── documentation ├── .gitignore ├── README.md ├── babel.config.js ├── docs │ ├── api │ │ ├── _category_.json │ │ ├── index.md │ │ └── parallax-controller │ │ │ ├── _category_.json │ │ │ ├── index.md │ │ │ ├── init.md │ │ │ └── methods.md │ ├── intro.md │ ├── performance.md │ └── usage │ │ ├── _category_.json │ │ ├── advanced.md │ │ ├── basic-usage.md │ │ └── props.md ├── docusaurus.config.js ├── package.json ├── sidebars.js ├── src │ ├── css │ │ └── custom.css │ └── pages │ │ └── index.tsx ├── static │ ├── .nojekyll │ └── img │ │ ├── favicon.ico │ │ └── joystick.png └── yarn.lock ├── package.json ├── src ├── classes │ ├── Element.test.ts │ ├── Element.ts │ ├── Limits.test.ts │ ├── Limits.ts │ ├── ParallaxController.test.ts │ ├── ParallaxController.ts │ ├── Rect.test.ts │ ├── Rect.ts │ ├── Scroll.test.ts │ ├── Scroll.ts │ ├── View.test.ts │ └── View.ts ├── constants.ts ├── globals.d.ts ├── helpers │ ├── clamp.ts │ ├── createEasingFunction.ts │ ├── createLimitsForRelativeElements.test.ts │ ├── createLimitsForRelativeElements.ts │ ├── createLimitsWithTranslationsForRelativeElements.test.ts │ ├── createLimitsWithTranslationsForRelativeElements.ts │ ├── elementStyles.test.ts │ ├── elementStyles.ts │ ├── getProgressAmount.test.ts │ ├── getProgressAmount.ts │ ├── getShouldScaleTranslateEffects.test.ts │ ├── getShouldScaleTranslateEffects.ts │ ├── getStartEndValueInPx.test.ts │ ├── getStartEndValueInPx.ts │ ├── getTranslateScalar.ts │ ├── isElementInView.test.ts │ ├── isElementInView.ts │ ├── parseElementTransitionEffects.test.ts │ ├── parseElementTransitionEffects.ts │ ├── scaleEffectByProgress.test.ts │ ├── scaleEffectByProgress.ts │ └── scaleTranslateEffectsForSlowerScroll.ts ├── index.ts ├── setupTests.ts ├── testUtils │ ├── createElementMock.ts │ └── createNodeMock.ts ├── types.ts └── utils │ ├── createId.ts │ ├── parseValueAndUnit.test.ts │ ├── parseValueAndUnit.ts │ ├── scaleBetween.test.ts │ ├── scaleBetween.ts │ ├── testForPassiveScroll.test.ts │ └── testForPassiveScroll.ts ├── tsconfig.json └── yarn.lock /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: Running Code Coverage 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | matrix: 11 | node-version: [12.x] 12 | 13 | steps: 14 | - name: Checkout repository 15 | uses: actions/checkout@v2 16 | with: 17 | fetch-depth: 2 18 | 19 | - name: Set up Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v1 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | 24 | - name: Install dependencies 25 | run: yarn install 26 | 27 | - name: Run the tests 28 | run: yarn test -- --coverage 29 | 30 | - name: Upload coverage to Codecov 31 | uses: codecov/codecov-action@v1 32 | with: 33 | token: ${{ secrets.CODECOV_TOKEN }} 34 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | jobs: 4 | build: 5 | name: Build, lint, and test on Node ${{ matrix.node }} and ${{ matrix.os }} 6 | 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | node: ['12.x', '14.x'] 11 | os: [ubuntu-latest, windows-latest, macOS-latest] 12 | 13 | steps: 14 | - name: Checkout repo 15 | uses: actions/checkout@v2 16 | 17 | - name: Use Node ${{ matrix.node }} 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: ${{ matrix.node }} 21 | 22 | - name: Install deps and build (with cache) 23 | uses: bahmutov/npm-install@v1 24 | 25 | - name: Lint 26 | run: yarn lint 27 | 28 | - name: Test 29 | run: yarn test --ci --coverage --maxWorkers=2 30 | 31 | - name: Build 32 | run: yarn build 33 | -------------------------------------------------------------------------------- /.github/workflows/size.yml: -------------------------------------------------------------------------------- 1 | name: size 2 | on: [pull_request] 3 | jobs: 4 | size: 5 | runs-on: ubuntu-latest 6 | env: 7 | CI_JOB_NUMBER: 1 8 | steps: 9 | - uses: actions/checkout@v1 10 | - uses: andresz1/size-limit-action@v1 11 | with: 12 | github_token: ${{ secrets.GITHUB_TOKEN }} 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | dist 5 | -------------------------------------------------------------------------------- /.np-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "branch": "master", 3 | "preview": false 4 | } 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | documentation/ 2 | docs/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 J Scott Smith 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 | # 🕹 Parallax Controller 2 | 3 | [![NPM Version Latest](https://img.shields.io/npm/v/parallax-controller/latest)](https://www.npmjs.com/package/parallax-controller) 4 | [![NPM Downloads](https://img.shields.io/npm/dm/parallax-controller)](https://www.npmjs.com/package/parallax-controller) 5 | [![Codecov](https://codecov.io/gh/jscottsmith/parallax-controller/branch/master/graph/badge.svg)](https://codecov.io/gh/jscottsmith/parallax-controller) 6 | 7 | [![Test and Lint](https://github.com/jscottsmith/parallax-controller/actions/workflows/main.yml/badge.svg)](https://github.com/jscottsmith/parallax-controller/actions/workflows/main.yml) 8 | [![Size](https://github.com/jscottsmith/parallax-controller/actions/workflows/size.yml/badge.svg)](https://github.com/jscottsmith/parallax-controller/actions/workflows/size.yml) 9 | [![Coverage](https://github.com/jscottsmith/parallax-controller/actions/workflows/coverage.yml/badge.svg)](https://github.com/jscottsmith/parallax-controller/actions/workflows/coverage.yml) 10 | 11 | Core classes and controller for creating parallax scrolling effects. Designed to provide scroll based animations for elements relative to the view. Built for performance by caching important attributes that cause reflow and layout when accessing. 12 | 13 | ## NPM Package 14 | 15 | Via Yarn 16 | 17 | ```bash 18 | yarn add parallax-controller 19 | ``` 20 | 21 | or NPM 22 | 23 | ```bash 24 | npm install parallax-controller 25 | ``` 26 | 27 | ## Documentation 28 | 29 | [API and Usage docs](https://parallax-controller.vercel.app/docs/intro) 30 | 31 | ## React Integration 32 | 33 | If you're building with React use `react-scroll-parallax`, a set of hooks and components to easily create effects and interact with the `parallax-controller`. 34 | 35 | ```bash 36 | yarn add react-scroll-parallax 37 | ``` 38 | 39 | See the [React Scroll Parallax documentation](https://react-scroll-parallax.damnthat.tv/) for usage and demos. 40 | 41 | ## Demos 42 | 43 | This package was created for [react-scroll-parallax](https://github.com/jscottsmith/react-scroll-parallax), but can be used as a standalone lib. Most demos were built with `react-scroll-parallax`. 44 | 45 | - [React Scroll Parallax V3 Doc Site](https://react-scroll-parallax-docs.netlify.app/) 46 | - [React Scroll Parallax V3 Storybook](https://react-scroll-parallax-v3.surge.sh/) 47 | -------------------------------------------------------------------------------- /docs/TSDX_README.md: -------------------------------------------------------------------------------- 1 | # TSDX User Guide 2 | 3 | Congrats! You just saved yourself hours of work by bootstrapping this project with TSDX. Let’s get you oriented with what’s here and how to use it. 4 | 5 | > This TSDX setup is meant for developing libraries (not apps!) that can be published to NPM. If you’re looking to build a Node app, you could use `ts-node-dev`, plain `ts-node`, or simple `tsc`. 6 | 7 | > If you’re new to TypeScript, checkout [this handy cheatsheet](https://devhints.io/typescript) 8 | 9 | ## Commands 10 | 11 | TSDX scaffolds your new library inside `/src`. 12 | 13 | To run TSDX, use: 14 | 15 | ```bash 16 | npm start # or yarn start 17 | ``` 18 | 19 | This builds to `/dist` and runs the project in watch mode so any edits you save inside `src` causes a rebuild to `/dist`. 20 | 21 | To do a one-off build, use `npm run build` or `yarn build`. 22 | 23 | To run tests, use `npm test` or `yarn test`. 24 | 25 | ## Configuration 26 | 27 | Code quality is set up for you with `prettier`, `husky`, and `lint-staged`. Adjust the respective fields in `package.json` accordingly. 28 | 29 | ### Jest 30 | 31 | Jest tests are set up to run with `npm test` or `yarn test`. 32 | 33 | ### Bundle Analysis 34 | 35 | [`size-limit`](https://github.com/ai/size-limit) is set up to calculate the real cost of your library with `npm run size` and visualize the bundle with `npm run analyze`. 36 | 37 | #### Setup Files 38 | 39 | This is the folder structure we set up for you: 40 | 41 | ```txt 42 | /src 43 | index.tsx # EDIT THIS 44 | /test 45 | blah.test.tsx # EDIT THIS 46 | .gitignore 47 | package.json 48 | README.md # EDIT THIS 49 | tsconfig.json 50 | ``` 51 | 52 | ### Rollup 53 | 54 | TSDX uses [Rollup](https://rollupjs.org) as a bundler and generates multiple rollup configs for various module formats and build settings. See [Optimizations](#optimizations) for details. 55 | 56 | ### TypeScript 57 | 58 | `tsconfig.json` is set up to interpret `dom` and `esnext` types, as well as `react` for `jsx`. Adjust according to your needs. 59 | 60 | ## Continuous Integration 61 | 62 | ### GitHub Actions 63 | 64 | Two actions are added by default: 65 | 66 | - `main` which installs deps w/ cache, lints, tests, and builds on all pushes against a Node and OS matrix 67 | - `size` which comments cost comparison of your library on every pull request using [`size-limit`](https://github.com/ai/size-limit) 68 | 69 | ## Optimizations 70 | 71 | Please see the main `tsdx` [optimizations docs](https://github.com/palmerhq/tsdx#optimizations). In particular, know that you can take advantage of development-only optimizations: 72 | 73 | ```js 74 | // ./types/index.d.ts 75 | declare var __DEV__: boolean; 76 | 77 | // inside your code... 78 | if (__DEV__) { 79 | console.log('foo'); 80 | } 81 | ``` 82 | 83 | You can also choose to install and use [invariant](https://github.com/palmerhq/tsdx#invariant) and [warning](https://github.com/palmerhq/tsdx#warning) functions. 84 | 85 | ## Module Formats 86 | 87 | CJS, ESModules, and UMD module formats are supported. 88 | 89 | The appropriate paths are configured in `package.json` and `dist/index.js` accordingly. Please report if any issues are found. 90 | 91 | ## Named Exports 92 | 93 | Per Palmer Group guidelines, [always use named exports.](https://github.com/palmerhq/typescript#exports) Code split inside your React app instead of your React library. 94 | 95 | ## Including Styles 96 | 97 | There are many ways to ship styles, including with CSS-in-JS. TSDX has no opinion on this, configure how you like. 98 | 99 | For vanilla CSS, you can include it at the root directory and add it to the `files` section in your `package.json`, so that it can be imported separately by your users and run through their bundler's loader. 100 | 101 | ## Publishing to NPM 102 | 103 | We recommend using [np](https://github.com/sindresorhus/np). 104 | -------------------------------------------------------------------------------- /documentation/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Production 5 | /build 6 | 7 | # Generated files 8 | .docusaurus 9 | .cache-loader 10 | 11 | # Misc 12 | .DS_Store 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | -------------------------------------------------------------------------------- /documentation/README.md: -------------------------------------------------------------------------------- 1 | # Website 2 | 3 | This website is built using [Docusaurus 2](https://docusaurus.io/), a modern static website generator. 4 | 5 | ### Installation 6 | 7 | ``` 8 | $ yarn 9 | ``` 10 | 11 | ### Local Development 12 | 13 | ``` 14 | $ yarn start 15 | ``` 16 | 17 | This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. 18 | 19 | ### Build 20 | 21 | ``` 22 | $ yarn build 23 | ``` 24 | 25 | This command generates static content into the `build` directory and can be served using any static contents hosting service. 26 | 27 | ### Deployment 28 | 29 | Using SSH: 30 | 31 | ``` 32 | $ USE_SSH=true yarn deploy 33 | ``` 34 | 35 | Not using SSH: 36 | 37 | ``` 38 | $ GIT_USER= yarn deploy 39 | ``` 40 | 41 | If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. 42 | -------------------------------------------------------------------------------- /documentation/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [require.resolve('@docusaurus/core/lib/babel/preset')], 3 | }; 4 | -------------------------------------------------------------------------------- /documentation/docs/api/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "API", 3 | "position": 3 4 | } 5 | -------------------------------------------------------------------------------- /documentation/docs/api/index.md: -------------------------------------------------------------------------------- 1 | # API Docs 2 | 3 | ## Classes 4 | 5 | - [ParallaxController](./parallax-controller/) 6 | -------------------------------------------------------------------------------- /documentation/docs/api/parallax-controller/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "ParallaxController", 3 | "position": 1 4 | } 5 | -------------------------------------------------------------------------------- /documentation/docs/api/parallax-controller/index.md: -------------------------------------------------------------------------------- 1 | # Parallax Controller Class 2 | 3 | The main controller for setting up and managing a scroll view of parallax elements. 4 | 5 | ```ts 6 | import { ParallaxController } from 'parallax-controller'; 7 | ``` 8 | 9 | ## Creating a New Controller 10 | 11 | ```ts 12 | const instance = ParallaxController.init(); 13 | ``` 14 | 15 | See the options for creating a new controller tied to a view: [`ParallaxController.init`](./init) 16 | 17 | ## Instance Methods 18 | 19 | - [`createElement()`](./methods#createelement) 20 | - [`getElements()`](./methods#getelements) 21 | - [`updateElementPropsById()`](./methods#updateelementpropsbyid) 22 | - [`removeElementById()`](./methods#removeelementbyid) 23 | - [`resetElementStyles()`](./methods#resetelementstyles) 24 | - [`updateScrollContainer()`](./methods#updatescrollcontainer) 25 | - [`disableParallaxController()`](./methods#disableParallaxController) 26 | - [`enableParallaxController()`](./methods#enableParallaxController) 27 | - [`update()`](./methods#update) 28 | - [`destroy()`](./methods#destroy) 29 | -------------------------------------------------------------------------------- /documentation/docs/api/parallax-controller/init.md: -------------------------------------------------------------------------------- 1 | # Creating An Instance 2 | 3 | Use the `ParallaxController.init()` method to instantiate a new controller tied to a view element. By default one will be created for `vertical` scrolling tied to the `body` scroll element. 4 | 5 | ```ts 6 | const instance = ParallaxController.init(); 7 | ``` 8 | 9 | ### With Options 10 | 11 | You can pass options to the `init()` method options that will change the default axis and provide a different scrolling element. 12 | 13 | ```ts 14 | const scrollContainer = document.getElementById('your-scroll-container'); 15 | const instance = ParallaxController.init({ 16 | scrollAxis: 'horizontal', 17 | scrollContainer, 18 | }); 19 | ``` 20 | 21 | ### Init Options 22 | 23 | The following option can be passed to `ParallaxController.init(...)`. 24 | 25 | | Option | Default | Description | 26 | | ----------------- | :----------: | -------------------------------------------------------------------------- | 27 | | `scrollAxis` | `'vertical'` | Direction of scroll for the element. One of `'vertical'` or `'horizontal'` | 28 | | `disabled` | false | Initial disabled state of the Parallax Controller | 29 | | `scrollContainer` | `window` | HTMLElement that will contain scroll elements. | 30 | -------------------------------------------------------------------------------- /documentation/docs/api/parallax-controller/methods.md: -------------------------------------------------------------------------------- 1 | # Public Methods 2 | 3 | The following methods are available on a controller instance 4 | 5 | ## createElement() 6 | 7 | Creates and returns a new parallax element with provided config including [props](/docs/usage/props) to be managed by the controller. 8 | 9 | ```ts 10 | const options = { 11 | el: document.querySelector('.your-element'), 12 | props: { translateY: [-100, 100] }, 13 | }; 14 | const element = parallaxController.createElement(options); 15 | ``` 16 | 17 | ## getElements() 18 | 19 | Returns all the parallax elements in the controller. 20 | 21 | ```ts 22 | const elements = parallaxController.getElements(); 23 | ``` 24 | 25 | ## updateElementPropsById() 26 | 27 | Updates an existing parallax element with new [props](/docs/usage/props). 28 | 29 | ```ts 30 | const newProps = { translateY: [-200, 200] }; 31 | parallaxController.updateElementPropsById(element.id, newProps); 32 | ``` 33 | 34 | ## removeElementById() 35 | 36 | Removes and element by a given ID 37 | 38 | ```ts 39 | parallaxController.removeElementById(element.id); 40 | ``` 41 | 42 | ## resetElementStyles() 43 | 44 | Remove a target elements parallax styles 45 | 46 | ```ts 47 | parallaxController.resetElementStyles(element); 48 | ``` 49 | 50 | ## updateScrollContainer() 51 | 52 | Updates the scroll container of the parallax controller. 53 | 54 | ```ts 55 | const el = document.getElementById('your-scroll-container'); 56 | parallaxController.updateScrollContainer(el); 57 | ``` 58 | 59 | ## disableParallaxController() 60 | 61 | Disables the Parallax Controller. 62 | 63 | ```ts 64 | parallaxController.disableParallaxController(); 65 | ``` 66 | 67 | ## enableParallaxController() 68 | 69 | Enables the Parallax Controller. 70 | 71 | ```ts 72 | parallaxController.enableParallaxController(); 73 | ``` 74 | 75 | ## update() 76 | 77 | Updates all cached attributes on parallax elements. 78 | 79 | ```ts 80 | parallaxController.update(); 81 | ``` 82 | 83 | ## destroy() 84 | 85 | Removes all listeners and resets all styles on managed elements. 86 | 87 | ```ts 88 | parallaxController.destroy(); 89 | ``` 90 | -------------------------------------------------------------------------------- /documentation/docs/intro.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_label: Introduction 3 | sidebar_position: 1 4 | --- 5 | 6 | # Parallax Controller: Introduction 7 | 8 | [![NPM Version Latest](https://img.shields.io/npm/v/parallax-controller/latest)](https://www.npmjs.com/package/parallax-controller) 9 | [![NPM Downloads](https://img.shields.io/npm/dm/parallax-controller)](https://www.npmjs.com/package/parallax-controller) 10 | [![Codecov](https://codecov.io/gh/jscottsmith/parallax-controller/branch/master/graph/badge.svg)](https://codecov.io/gh/jscottsmith/parallax-controller) 11 | 12 | [![Test and Lint](https://github.com/jscottsmith/parallax-controller/actions/workflows/main.yml/badge.svg)](https://github.com/jscottsmith/parallax-controller/actions/workflows/main.yml) 13 | [![Size](https://github.com/jscottsmith/parallax-controller/actions/workflows/size.yml/badge.svg)](https://github.com/jscottsmith/parallax-controller/actions/workflows/size.yml) 14 | [![Coverage](https://github.com/jscottsmith/parallax-controller/actions/workflows/coverage.yml/badge.svg)](https://github.com/jscottsmith/parallax-controller/actions/workflows/coverage.yml) 15 | 16 | Core classes and controller for creating parallax scrolling effects. Designed to provide scroll based animations for elements relative to the view. Built for performance by caching important attributes that cause reflow and layout when accessing. 17 | 18 | ## NPM Package 19 | 20 | Via Yarn 21 | 22 | ```bash 23 | yarn add parallax-controller 24 | ``` 25 | 26 | or NPM 27 | 28 | ```bash 29 | npm install parallax-controller 30 | ``` 31 | 32 | ## React Integration 33 | 34 | If you're building with React use `react-scroll-parallax`, a set of hooks and components to easily create effects and interact with the `parallax-controller`. 35 | 36 | ```bash 37 | yarn add react-scroll-parallax 38 | ``` 39 | 40 | See the [React Scroll Parallax documentation](https://react-scroll-parallax.damnthat.tv/) for usage and demos. 41 | 42 | ## Demos 43 | 44 | This package was created for [react-scroll-parallax](https://github.com/jscottsmith/react-scroll-parallax), but can be used as a standalone lib. Most demos were built with `react-scroll-parallax`. 45 | 46 | - [React Scroll Parallax V3 Doc Site](https://react-scroll-parallax-docs.netlify.app/) 47 | - [React Scroll Parallax V3 Storybook](https://react-scroll-parallax-v3.surge.sh/) 48 | -------------------------------------------------------------------------------- /documentation/docs/performance.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_label: Performance 3 | sidebar_position: 2 4 | --- 5 | 6 | # Performance 7 | 8 | Scroll effects can impact performance, however a number of techniques are used to keep scrolling performance optimized. 9 | 10 | ## Optimizations to Reduce Jank 11 | 12 | 1. Uses a single passive scroll listener to control all animated elements on the page. 13 | 2. A minimal amount of work is done on the scroll event with no calls to methods that cause layout shifts. 14 | 3. Calculations that cause layout, reflow ([`getBoundingClientRect()`](https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect)) are cached and only updated when layout may change. 15 | 4. A non-blocking [`requestAnimationFrame`](https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame) is used to apply all scroll effects. 16 | 5. Only GPU supported CSS effects `transform` and `opacity` are allowed. 17 | 6. CSS [`will-change`](https://developer.mozilla.org/en-US/docs/Web/CSS/will-change) is added to an element based on animation effects to prevent paints. 18 | 19 | If you have ideas to further optimize scrolling [please PR or post an issue](https://github.com/jscottsmith/parallax-controller). 20 | 21 | ## Scroll Effects May Still Cause Bad User Experiences! 22 | 23 | **It's up to you to make sure you use this package appropriately.** 24 | 25 | Here's some suggestions for usage while maintaining good UX: 26 | 27 | 1. Keep effects simple -- less is more. Oftentimes the less extreme animations on the page the better the scrolling will be. 28 | 2. Minimize the number of scroll effects on elements that are in view at the same time. 29 | 3. When using images keep them small and optimized. Hi-resolution images will hurt scroll performance. 30 | 4. Disable most (or all) scroll effects on mobile. Mobile devices optimize for best battery life and animation performance will often be degraded. 31 | -------------------------------------------------------------------------------- /documentation/docs/usage/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Usage", 3 | "position": 2 4 | } 5 | -------------------------------------------------------------------------------- /documentation/docs/usage/advanced.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | --- 4 | 5 | # Advanced Usage 6 | 7 | There are a few ways to change how the scroll progress of an element is calculated. 8 | 9 | ## Setting Scroll Top Values 10 | 11 | You can set `startScroll` and `startScroll` representing the `scrollTop` values to animate between. 12 | 13 | ```ts 14 | const props = { startScroll: 0, endScroll: 1000 }; 15 | 16 | controller.createElement({ 17 | el: document.querySelector('.your-element'), 18 | props, 19 | }); 20 | ``` 21 | 22 | ## Using a Target Element 23 | 24 | A different element can also be used to track scroll progress. Assign a `targetElement` when creating a new parallax element. 25 | 26 | ```ts 27 | const props = { targetElement: document.getElementById('target') }; 28 | 29 | controller.createElement({ 30 | el: document.querySelector('.your-element'), 31 | props, 32 | }); 33 | ``` 34 | 35 | ## Increase Scroll Bounds 36 | 37 | You can add a `rootMargin` that will adjust the bounds that determine when an element is in view. 38 | 39 | ```ts 40 | const props = { rootMargin: { top: 100, right: 100, bottom: 100, left: 100 } }; 41 | 42 | controller.createElement({ 43 | el: document.querySelector('.your-element'), 44 | props, 45 | }); 46 | ``` 47 | -------------------------------------------------------------------------------- /documentation/docs/usage/basic-usage.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | --- 4 | 5 | # Basic Usage 6 | 7 | Create the parallax controller first: 8 | 9 | ```ts 10 | const controller = ParallaxController.init(); 11 | ``` 12 | 13 | Then create an element with [animation effects](./props) as props: 14 | 15 | ```ts 16 | controller.createElement({ 17 | el: document.querySelector('.your-element'), 18 | props: { 19 | translateY: [-100, 100], 20 | opacity: [0.4, 1], 21 | }, 22 | }); 23 | ``` 24 | 25 | ```html 26 |
27 | 28 |
29 | ``` 30 | -------------------------------------------------------------------------------- /documentation/docs/usage/props.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 3 3 | --- 4 | 5 | # Props 6 | 7 | All available options for scroll effect animation configurations of a parallax element are defined in the `props` option. 8 | 9 | ```ts 10 | parallaxController.createElement({ 11 | el: document.querySelector('.your-element'), 12 | props: { 13 | // ...your props here 14 | }, 15 | }); 16 | ``` 17 | 18 | ## Configuration Props 19 | 20 | The following properties can be provided to configure the scroll animation: 21 | 22 | | Name | Type | Default | Description | 23 | | ------------------------------------ | :--------------------: | :------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 24 | | **speed** | `number` | | A value representing the elements scroll speed. If less than zero scroll will appear slower. If greater than zero scroll will appear faster. | 25 | | **easing** | `string` or `number[]` | | String representing an [easing preset](#easing-presets) or array of params to supply to a [cubic bezier easing function](#cubic-bezier-easing-function). | 26 | | **rootMargin** | `object` | | Margin to be applied as the bounds around an element. This will affect when an element is determined to be considered in the viewport. Example: `{ top: 100, right: 100, bottom: 100, left: 100 }` | 27 | | **disabled** | `boolean` | `false` | Disables parallax effects on individual elements when `true`. | 28 | | **shouldAlwaysCompleteAnimation** | `boolean` | `false` | Always start and end animations at the given effect values - if the element is positioned inside the view when scroll is at zero or ends in view at final scroll position, the initial and final positions are used to determine progress instead of the scroll view size. | 29 | | **shouldDisableScalingTranslations** | `boolean` | `false` | Enable scaling translations - translate effects that cause the element to appear in the view longer must be scaled up so that animation doesn't end early. | 30 | | **startScroll** | `number` | | Scroll top value to begin the animation. When provided along with `endScroll` relative scroll values will be ignored. | 31 | | **endScroll** | `number` | | Scroll top value to end the animation. When provided along with `startScroll` relative scroll values will be ignored. | 32 | | **targetElement** | `HTMLElement` | | Provides an element to track and determine the scroll progress. Use when scroll progress should be independent of parallax element's original position. See [storybook for example](https://react-scroll-parallax-v3.surge.sh/?path=/story/components-parallax-vertical-scroll--with-defined-target-element). | 33 | 34 | ## CSS Effect Props 35 | 36 | All props for creating CSS effects are defined by a **start** and **end** value represented by an `array`. 37 | 38 | ```ts 39 | const translateY = [-100, 100]; 40 | 41 | parallaxController.createElement({ 42 | el: document.querySelector('.your-element'), 43 | props: { 44 | translateY, 45 | }, 46 | }); 47 | ``` 48 | 49 | ### How Effects Progress 50 | 51 | The **start** of an effect begins when the top of the element enters the bottom of the view. 52 | 53 | The **end** of an effect begins when the bottom of the element exits the top of the view. 54 | 55 | ### Available CSS Effects 56 | 57 | These are all the supported CSS effects: 58 | 59 | | Name | Type | Description | 60 | | -------------- | :----------------------: | ------------------------------------------------------------------------------------------------------------------------------------------------- | 61 | | **translateX** | `string[]` or `number[]` | Start and end translation on x-axis in `%`, `px`, `vw` or `vh`. If no unit is passed percent is assumed. Percent is based on the elements width. | 62 | | **translateY** | `string[]` or `number[]` | Start and end translation on y-axis in `%`, `px`, `vw` or `vh`. If no unit is passed percent is assumed. Percent is based on the elements height. | 63 | | **rotate** | `string[]` or `number[]` | Start and end rotation on z-axis in `deg`, `rad`, or `turn`. If no unit is passed `deg` is assumed. | 64 | | **rotateX** | `string[]` or `number[]` | Start and end rotation on x-axis in `deg`, `rad`, or `turn`. If no unit is passed `deg` is assumed. | 65 | | **rotateY** | `string[]` or `number[]` | Start and end rotation on y-axis in `deg`, `rad`, or `turn`. If no unit is passed `deg` is assumed. | 66 | | **rotateZ** | `string[]` or `number[]` | Start and end rotation on z-axis in `deg`, `rad`, or `turn`. If no unit is passed `deg` is assumed. | 67 | | **scale** | `number[]` | Start and end scale on x-axis and y-axis. | 68 | | **scaleX** | `number[]` | Start and end scale on x-axis. | 69 | | **scaleY** | `number[]` | Start and end scale on y-axis. | 70 | | **scaleZ** | `number[]` | Start and end scale on z-axis. | 71 | | **opacity** | `number[]` | Start and end opacity value. | 72 | 73 | ## Callback Props 74 | 75 | Example using `onChange` callback 76 | 77 | ```ts 78 | const onChange = element => console.log(element); 79 | 80 | parallaxController.createElement({ 81 | el: document.querySelector('.your-element'), 82 | props: { 83 | onChange, 84 | }, 85 | }); 86 | ``` 87 | 88 | All available callbacks: 89 | 90 | | Name | Type | Description | 91 | | -------------------- | :--------: | ------------------------------------------------------------------------------------------------------------ | 92 | | **onProgressChange** | `function` | Callback for when the progress of an element in the viewport changes. | 93 | | **onChange** | `function` | Callback for when the progress of an element in the viewport changes and includes the Element as a parameter | 94 | | **onEnter** | `function` | Callback for when an element enters the viewport and includes the Element as a parameter. | 95 | | **onExit** | `function` | Callback for when an element exits the viewport and includes the Element as a parameter. | 96 | 97 | ## Easing Presets 98 | 99 | Example of setting easing: 100 | 101 | ```ts 102 | const easing = 'easeInCubic'; 103 | 104 | parallaxController.createElement({ 105 | el: document.querySelector('.your-element'), 106 | props: { 107 | easing, 108 | }, 109 | }); 110 | ``` 111 | 112 | The following easing values are preset and can be used as easing 113 | 114 | ``` 115 | ease 116 | easeIn 117 | easeOut 118 | easeInOut 119 | easeInQuad 120 | easeInCubic 121 | easeInQuart 122 | easeInQuint 123 | easeInSine 124 | easeInExpo 125 | easeInCirc 126 | easeOutQuad 127 | easeOutCubic 128 | easeOutQuart 129 | easeOutQuint 130 | easeOutSine 131 | easeOutExpo 132 | easeOutCirc 133 | easeInOutQuad 134 | easeInOutCubic 135 | easeInOutQuart 136 | easeInOutQuint 137 | easeInOutSine 138 | easeInOutExpo 139 | easeInOutCirc 140 | easeInBack 141 | easeOutBack 142 | easeInOutBack 143 | ``` 144 | 145 | ### Easing Individual Effects 146 | 147 | You can provide various easing values to each effect by defining it as the third element in the array 148 | 149 | ```ts 150 | const translateY = [-100, 100, 'easeInOut']; 151 | const scale = [0, 1, 'easeOutBack']; 152 | 153 | parallaxController.createElement({ 154 | el: document.querySelector('.your-element'), 155 | props: { 156 | translateY, 157 | scale, 158 | }, 159 | }); 160 | ``` 161 | 162 | ### Cubic Bezier Easing Function 163 | 164 | Just like with CSS `cubic-bezier(0.2,-0.67,1,-0.62);`, you can supply the 4 params to a custom bezier function. 165 | 166 | ```ts 167 | const easing = [0.2, -0.6, 1, -0.6]; 168 | ``` 169 | -------------------------------------------------------------------------------- /documentation/docusaurus.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | // Note: type annotations allow type checking and IDEs autocompletion 3 | 4 | const lightCodeTheme = require('prism-react-renderer/themes/github'); 5 | const darkCodeTheme = require('prism-react-renderer/themes/dracula'); 6 | 7 | /** @type {import('@docusaurus/types').Config} */ 8 | const config = { 9 | title: 'Parallax Controller', 10 | tagline: 11 | 'Core classes and controller for creating parallax scrolling effects', 12 | url: 'https://parallax-controller.damnthat.tv', 13 | baseUrl: '/', 14 | onBrokenLinks: 'throw', 15 | onBrokenMarkdownLinks: 'warn', 16 | favicon: 'img/favicon.ico', 17 | organizationName: 'jscottsmith', // Usually your GitHub org/user name. 18 | projectName: 'parallax-controller', // Usually your repo name. 19 | 20 | presets: [ 21 | [ 22 | 'classic', 23 | /** @type {import('@docusaurus/preset-classic').Options} */ 24 | ({ 25 | docs: { 26 | sidebarPath: require.resolve('./sidebars.js'), 27 | // Please change this to your repo. 28 | editUrl: 29 | 'https://github.com/facebook/docusaurus/tree/main/packages/create-docusaurus/templates/shared/', 30 | }, 31 | theme: { 32 | customCss: require.resolve('./src/css/custom.css'), 33 | }, 34 | }), 35 | ], 36 | ], 37 | 38 | themeConfig: 39 | /** @type {import('@docusaurus/preset-classic').ThemeConfig} */ 40 | ({ 41 | algolia: { 42 | appId: 'YVJ9L8IGS3', 43 | // Public API key: it is safe to commit it 44 | apiKey: '59df3c62f6a1a15d23659f198810311f', 45 | indexName: 'parallax-controller', 46 | contextualSearch: true, 47 | externalUrlRegex: 'external\\.com|domain\\.com', 48 | searchParameters: {}, 49 | }, 50 | navbar: { 51 | title: 'Parallax Controller', 52 | logo: { 53 | alt: 'Joystick Emoji', 54 | src: 'img/joystick.png', 55 | }, 56 | items: [ 57 | { 58 | type: 'doc', 59 | docId: 'performance', 60 | position: 'left', 61 | label: 'Performance', 62 | }, 63 | { 64 | type: 'doc', 65 | docId: 'usage/basic-usage', 66 | position: 'left', 67 | label: 'Usage', 68 | }, 69 | { 70 | type: 'doc', 71 | docId: 'api/index', 72 | position: 'left', 73 | label: 'API', 74 | }, 75 | { 76 | href: 'https://github.com/jscottsmith/parallax-controller', 77 | className: 'header-github-link', 78 | 'aria-label': 'GitHub repository', 79 | position: 'right', 80 | }, 81 | ], 82 | }, 83 | footer: { 84 | style: 'dark', 85 | links: [ 86 | { 87 | title: 'Docs', 88 | items: [ 89 | { 90 | label: 'Introduction', 91 | to: '/docs/intro', 92 | }, 93 | 94 | { 95 | to: '/docs/performance', 96 | label: 'Performance', 97 | }, 98 | 99 | { 100 | to: '/docs/usage/basic-usage', 101 | label: 'Usage', 102 | }, 103 | { 104 | to: '/docs/api/', 105 | label: 'API', 106 | }, 107 | ], 108 | }, 109 | { 110 | title: 'Elsewhere', 111 | items: [ 112 | { 113 | label: 'NPM', 114 | href: 'https://www.npmjs.com/package/parallax-controller', 115 | }, 116 | { 117 | label: 'Github', 118 | href: 'https://github.com/jscottsmith/parallax-controller', 119 | }, 120 | { 121 | label: 'Support', 122 | href: 123 | 'https://github.com/jscottsmith/parallax-controller/issues', 124 | }, 125 | ], 126 | }, 127 | { 128 | title: 'Who', 129 | items: [ 130 | { 131 | label: 'J', 132 | href: 'https://github.com/jscottsmith', 133 | }, 134 | { 135 | label: 'Damnthat.tv', 136 | href: 'https://damnthat.tv/', 137 | }, 138 | { 139 | label: '@damntelevision', 140 | href: 'https://twitter.com/damntelevision', 141 | }, 142 | ], 143 | }, 144 | ], 145 | copyright: `It's ok 👌🏻`, 146 | }, 147 | prism: { 148 | theme: lightCodeTheme, 149 | darkTheme: darkCodeTheme, 150 | }, 151 | }), 152 | }; 153 | 154 | module.exports = config; 155 | -------------------------------------------------------------------------------- /documentation/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "documentation", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "docusaurus": "docusaurus", 7 | "start": "docusaurus start", 8 | "build": "docusaurus build", 9 | "swizzle": "docusaurus swizzle", 10 | "deploy": "docusaurus deploy", 11 | "clear": "docusaurus clear", 12 | "serve": "docusaurus serve", 13 | "write-translations": "docusaurus write-translations", 14 | "write-heading-ids": "docusaurus write-heading-ids" 15 | }, 16 | "dependencies": { 17 | "@docusaurus/core": "2.0.0-beta.18", 18 | "@docusaurus/preset-classic": "2.0.0-beta.18", 19 | "@mdx-js/react": "^1.6.21", 20 | "clsx": "^1.1.1", 21 | "prism-react-renderer": "^1.3.1", 22 | "react": "^17.0.1", 23 | "react-dom": "^17.0.1" 24 | }, 25 | "browserslist": { 26 | "production": [ 27 | ">0.5%", 28 | "not dead", 29 | "not op_mini all" 30 | ], 31 | "development": [ 32 | "last 1 chrome version", 33 | "last 1 firefox version", 34 | "last 1 safari version" 35 | ] 36 | } 37 | } -------------------------------------------------------------------------------- /documentation/sidebars.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creating a sidebar enables you to: 3 | - create an ordered group of docs 4 | - render a sidebar for each doc of that group 5 | - provide next/previous navigation 6 | 7 | The sidebars can be generated from the filesystem, or explicitly defined here. 8 | 9 | Create as many sidebars as you want. 10 | */ 11 | 12 | // @ts-check 13 | 14 | /** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */ 15 | const sidebars = { 16 | // By default, Docusaurus generates a sidebar from the docs folder structure 17 | tutorialSidebar: [{type: 'autogenerated', dirName: '.'}], 18 | 19 | // But you can create a sidebar manually 20 | /* 21 | tutorialSidebar: [ 22 | { 23 | type: 'category', 24 | label: 'Tutorial', 25 | items: ['hello'], 26 | }, 27 | ], 28 | */ 29 | }; 30 | 31 | module.exports = sidebars; 32 | -------------------------------------------------------------------------------- /documentation/src/css/custom.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Any CSS included here will be global. The classic template 3 | * bundles Infima by default. Infima is a CSS framework designed to 4 | * work well for content-centric websites. 5 | */ 6 | 7 | /* You can override the default Infima variables here. */ 8 | :root { 9 | --ifm-color-primary: #6c75fa; 10 | --ifm-color-primary-dark: #5f68e9; 11 | --ifm-color-primary-darker: #414aca; 12 | --ifm-color-primary-darkest: #2d338a; 13 | --ifm-color-primary-light: #737cf8; 14 | --ifm-color-primary-lighter: #8b92f7; 15 | --ifm-color-primary-lightest: #969df5; 16 | --ifm-code-font-size: 95%; 17 | } 18 | 19 | .docusaurus-highlight-code-line { 20 | background-color: rgba(0, 0, 0, 0.1); 21 | display: block; 22 | margin: 0 calc(-1 * var(--ifm-pre-padding)); 23 | padding: 0 var(--ifm-pre-padding); 24 | } 25 | 26 | html[data-theme='dark'] .docusaurus-highlight-code-line { 27 | background-color: rgba(0, 0, 0, 0.3); 28 | } 29 | 30 | .header-github-link:hover { 31 | opacity: 0.6; 32 | } 33 | 34 | .header-github-link:before { 35 | content: ''; 36 | width: 24px; 37 | height: 24px; 38 | display: flex; 39 | background: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12'/%3E%3C/svg%3E") 40 | no-repeat; 41 | } 42 | 43 | html[data-theme='dark'] .header-github-link:before { 44 | background: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='white' d='M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12'/%3E%3C/svg%3E") 45 | no-repeat; 46 | } 47 | -------------------------------------------------------------------------------- /documentation/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Redirect } from '@docusaurus/router'; 3 | 4 | const Home = () => { 5 | return ; 6 | }; 7 | 8 | export default Home; 9 | -------------------------------------------------------------------------------- /documentation/static/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jscottsmith/parallax-controller/2ff397bcbf13e3801cf7cd844119ba02a3b1c9fa/documentation/static/.nojekyll -------------------------------------------------------------------------------- /documentation/static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jscottsmith/parallax-controller/2ff397bcbf13e3801cf7cd844119ba02a3b1c9fa/documentation/static/img/favicon.ico -------------------------------------------------------------------------------- /documentation/static/img/joystick.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jscottsmith/parallax-controller/2ff397bcbf13e3801cf7cd844119ba02a3b1c9fa/documentation/static/img/joystick.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.7.1", 3 | "license": "MIT", 4 | "main": "dist/index.js", 5 | "typings": "dist/index.d.ts", 6 | "publishConfig": { 7 | "access": "public" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/jscottsmith/parallax-controller" 12 | }, 13 | "files": [ 14 | "dist", 15 | "src" 16 | ], 17 | "engines": { 18 | "node": ">=12" 19 | }, 20 | "scripts": { 21 | "start": "tsdx watch", 22 | "build": "tsdx build", 23 | "test": "tsdx test", 24 | "lint": "tsdx lint", 25 | "prepare": "tsdx build", 26 | "size": "size-limit", 27 | "analyze": "size-limit --why", 28 | "release": "np" 29 | }, 30 | "peerDependencies": {}, 31 | "husky": { 32 | "hooks": { 33 | "pre-commit": "tsdx lint" 34 | } 35 | }, 36 | "prettier": { 37 | "printWidth": 80, 38 | "tabWidth": 2, 39 | "semi": true, 40 | "singleQuote": true, 41 | "trailingComma": "es5" 42 | }, 43 | "name": "parallax-controller", 44 | "description": "Core classes and controller for creating parallax scrolling effects", 45 | "keywords": [ 46 | "scroll", 47 | "effects", 48 | "parallax", 49 | "animation", 50 | "transform", 51 | "translate", 52 | "rotate", 53 | "scale", 54 | "opacity" 55 | ], 56 | "author": "J Scott Smith", 57 | "module": "dist/parallax-controller.esm.js", 58 | "size-limit": [ 59 | { 60 | "path": "dist/parallax-controller.cjs.production.min.js", 61 | "limit": "10 KB" 62 | }, 63 | { 64 | "path": "dist/parallax-controller.esm.js", 65 | "limit": "10 KB" 66 | } 67 | ], 68 | "jest": { 69 | "setupFiles": [ 70 | "./src/setupTests.ts" 71 | ] 72 | }, 73 | "devDependencies": { 74 | "@size-limit/preset-small-lib": "^7.0.3", 75 | "husky": "^7.0.4", 76 | "np": "^7.6.0", 77 | "size-limit": "^7.0.3", 78 | "tsdx": "^0.14.1", 79 | "tslib": "^2.3.1", 80 | "typescript": "^4.5.2" 81 | }, 82 | "dependencies": { 83 | "bezier-easing": "^2.1.0" 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/classes/Element.test.ts: -------------------------------------------------------------------------------- 1 | import { Element } from './Element'; 2 | import { View } from './View'; 3 | import { Scroll } from './Scroll'; 4 | import { Rect } from './Rect'; 5 | import { Limits } from './Limits'; 6 | import { createElementMock } from '../testUtils/createElementMock'; 7 | import { ScrollAxis } from '../types'; 8 | import { easingPresets } from '../constants'; 9 | import { CSSEffect } from '..'; 10 | 11 | const DEFAULT_OPTIONS = { 12 | el: createElementMock( 13 | { offsetWidth: 100, offsetHeight: 100 }, 14 | { 15 | getBoundingClientRect: () => ({ 16 | top: 500, 17 | left: 0, 18 | bottom: 600, 19 | right: 100, 20 | }), 21 | } 22 | ), 23 | scrollAxis: ScrollAxis.vertical, 24 | props: { translateX: [0, 0] as CSSEffect, translateY: [0, 0] as CSSEffect }, 25 | }; 26 | 27 | const DEFAULT_VIEW = new View({ 28 | width: 768, 29 | height: 1024, 30 | scrollHeight: 3000, 31 | scrollWidth: 768, 32 | }); 33 | 34 | const DEFAULT_SCROLL = new Scroll(0, 0); 35 | 36 | describe('Expect the Element class', () => { 37 | it('to construct', () => { 38 | const element = new Element(DEFAULT_OPTIONS); 39 | expect(element).toMatchObject(DEFAULT_OPTIONS); 40 | }); 41 | 42 | it('to update props and return the instance', () => { 43 | const element = new Element(DEFAULT_OPTIONS); 44 | const updates = { 45 | disabled: true, 46 | translateX: [100, 100] as CSSEffect, 47 | translateY: [0, 0] as CSSEffect, 48 | }; 49 | const instance = element.updateProps(updates); 50 | expect(instance.props).toMatchObject(updates); 51 | expect(instance).toBeInstanceOf(Element); 52 | }); 53 | 54 | it('to creates a rect and limits when calling setCachedAttributes method', () => { 55 | const element = new Element(DEFAULT_OPTIONS); 56 | expect(element.rect).toBeUndefined(); 57 | expect(element.limits).toBeUndefined(); 58 | expect(element.scaledEffects).toBeUndefined(); 59 | element.setCachedAttributes(DEFAULT_VIEW, DEFAULT_SCROLL); 60 | expect(element.rect).toBeInstanceOf(Rect); 61 | expect(element.limits).toBeInstanceOf(Limits); 62 | expect(element.scaledEffects).toBeDefined(); 63 | }); 64 | 65 | it('to creates scaledEffects when calling setCachedAttributes method with translate props and no root margin', () => { 66 | const element = new Element(DEFAULT_OPTIONS); 67 | 68 | expect(element.scaledEffects).toBeUndefined(); 69 | element.setCachedAttributes(DEFAULT_VIEW, DEFAULT_SCROLL); 70 | expect(element.scaledEffects).toEqual({ 71 | translateX: { easing: undefined, end: 0, start: 0, unit: '%' }, 72 | translateY: { easing: undefined, end: 0, start: 0, unit: '%' }, 73 | }); 74 | }); 75 | 76 | it('set will change styles in constructor', () => { 77 | const element = new Element(DEFAULT_OPTIONS); 78 | expect(element.el?.style.willChange).toEqual('transform'); 79 | }); 80 | 81 | it('set limits based on user provided start end scroll values', () => { 82 | const element = new Element({ 83 | ...DEFAULT_OPTIONS, 84 | props: { ...DEFAULT_OPTIONS.props, startScroll: 0, endScroll: 999 }, 85 | }); 86 | element.setCachedAttributes(DEFAULT_VIEW, DEFAULT_SCROLL); 87 | expect(element.limits?.startX).toEqual(0); 88 | expect(element.limits?.startY).toEqual(0); 89 | expect(element.limits?.endX).toEqual(999); 90 | expect(element.limits?.endY).toEqual(999); 91 | }); 92 | 93 | it.skip('to conditionally handle updates based on scroll axis', () => {}); 94 | 95 | it('calls enter and exit and progress handlers', () => { 96 | const onEnter = jest.fn(); 97 | const onExit = jest.fn(); 98 | const onChange = jest.fn(); 99 | const onProgressChange = jest.fn(); 100 | 101 | const element = new Element({ 102 | ...DEFAULT_OPTIONS, 103 | props: { 104 | onEnter, 105 | onExit, 106 | onChange, 107 | onProgressChange, 108 | translateY: [100, -100], 109 | }, 110 | }); 111 | const view = new View({ 112 | width: 100, 113 | height: 100, 114 | scrollWidth: 100, 115 | scrollHeight: 200, 116 | scrollContainer: createElementMock(), 117 | }); 118 | 119 | const scroll = new Scroll(0, 0); 120 | element.setCachedAttributes(view, scroll); 121 | expect(onChange).toBeCalledTimes(0); 122 | expect(onProgressChange).toBeCalledTimes(0); 123 | 124 | element.updatePosition(scroll); 125 | expect(onChange).toBeCalledTimes(0); 126 | expect(onProgressChange).toBeCalledTimes(0); 127 | 128 | scroll.setScroll(0, 500); 129 | element.updatePosition(scroll); 130 | expect(onEnter).toBeCalledTimes(1); 131 | expect(onChange).toBeCalledTimes(1); 132 | expect(onProgressChange).toBeCalledTimes(1); 133 | 134 | scroll.setScroll(0, 0); 135 | element.updatePosition(scroll); 136 | expect(onExit).toBeCalledTimes(1); 137 | expect(onChange).toBeCalledTimes(2); 138 | expect(onProgressChange).toBeCalledTimes(2); 139 | }); 140 | 141 | it('to set cache and return the instance', () => { 142 | const element = new Element(DEFAULT_OPTIONS); 143 | const view = new View({ 144 | width: 100, 145 | height: 50, 146 | scrollWidth: 100, 147 | scrollHeight: 200, 148 | scrollContainer: createElementMock(), 149 | }); 150 | const scroll = new Scroll(0, 40); 151 | const instance = element.setCachedAttributes(view, scroll); 152 | expect(instance).toBeInstanceOf(Element); 153 | }); 154 | 155 | it('to update position and return the instance', () => { 156 | const element = new Element(DEFAULT_OPTIONS); 157 | const view = new View({ 158 | width: 100, 159 | height: 50, 160 | scrollWidth: 100, 161 | scrollHeight: 200, 162 | scrollContainer: createElementMock(), 163 | }); 164 | const scroll = new Scroll(0, 0); 165 | element.setCachedAttributes(view, scroll); 166 | scroll.setScroll(0, 100); 167 | 168 | const instance = element.updatePosition(scroll); 169 | expect(instance).toBeInstanceOf(Element); 170 | }); 171 | 172 | it('to create an easing function when arguments are provided', () => { 173 | const element = new Element({ 174 | el: document.createElement('div'), 175 | scrollAxis: ScrollAxis.vertical, 176 | props: { 177 | easing: [0, 0, 1, 0.5], 178 | }, 179 | }); 180 | 181 | expect(element.easing).toBeInstanceOf(Function); 182 | }); 183 | 184 | describe('to create an easing function with valid preset:', () => { 185 | Object.keys(easingPresets).forEach(key => { 186 | test(key, () => { 187 | const element = new Element({ 188 | el: document.createElement('div'), 189 | scrollAxis: ScrollAxis.vertical, 190 | props: { 191 | easing: [0, 0, 1, 0.5], 192 | }, 193 | }); 194 | 195 | expect(element.easing).toBeInstanceOf(Function); 196 | }); 197 | }); 198 | }); 199 | 200 | it('to NOT create an easing function when arguments are omitted', () => { 201 | const element = new Element({ 202 | el: document.createElement('div'), 203 | scrollAxis: ScrollAxis.vertical, 204 | props: {}, 205 | }); 206 | 207 | expect(element.easing).toBeUndefined(); 208 | }); 209 | 210 | it('to update easing when element props are updated', () => { 211 | const element = new Element({ 212 | el: document.createElement('div'), 213 | scrollAxis: ScrollAxis.vertical, 214 | props: {}, 215 | }); 216 | 217 | expect(element.easing).toBeUndefined(); 218 | 219 | element.updateProps({ easing: [0, 0, 1, 0.5] }); 220 | 221 | expect(element.easing).toBeInstanceOf(Function); 222 | }); 223 | }); 224 | -------------------------------------------------------------------------------- /src/classes/Element.ts: -------------------------------------------------------------------------------- 1 | import bezier from 'bezier-easing'; 2 | import { 3 | CreateElementOptions, 4 | ParallaxElementConfig, 5 | ParallaxStartEndEffects, 6 | ScrollAxis, 7 | ValidScrollAxis, 8 | EasingParam, 9 | } from '../types'; 10 | import { createId } from '../utils/createId'; 11 | import { Rect } from './Rect'; 12 | import { View } from './View'; 13 | import { Scroll } from './Scroll'; 14 | import { Limits } from './Limits'; 15 | import { parseElementTransitionEffects } from '../helpers/parseElementTransitionEffects'; 16 | import { getProgressAmount } from '../helpers/getProgressAmount'; 17 | import { isElementInView } from '../helpers/isElementInView'; 18 | import { 19 | resetStyles, 20 | setElementStyles, 21 | setWillChangeStyles, 22 | } from '../helpers/elementStyles'; 23 | import { createEasingFunction } from '../helpers/createEasingFunction'; 24 | import { createLimitsForRelativeElements } from '../helpers/createLimitsForRelativeElements'; 25 | import { createLimitsWithTranslationsForRelativeElements } from '../helpers/createLimitsWithTranslationsForRelativeElements'; 26 | import { scaleTranslateEffectsForSlowerScroll } from '../helpers/scaleTranslateEffectsForSlowerScroll'; 27 | import { getShouldScaleTranslateEffects } from '../helpers/getShouldScaleTranslateEffects'; 28 | import { clamp } from '../helpers/clamp'; 29 | 30 | type ParallaxControllerConstructorOptions = { 31 | scrollAxis: ValidScrollAxis; 32 | disabledParallaxController?: boolean; 33 | }; 34 | type ElementConstructorOptions = CreateElementOptions & 35 | ParallaxControllerConstructorOptions; 36 | 37 | export class Element { 38 | el: HTMLElement; 39 | props: ParallaxElementConfig; 40 | scrollAxis: ValidScrollAxis; 41 | disabledParallaxController: boolean; 42 | id: number; 43 | effects: ParallaxStartEndEffects; 44 | isInView: boolean | null; 45 | progress: number; 46 | /* Optionally set if translate effect must be scaled */ 47 | scaledEffects?: ParallaxStartEndEffects; 48 | rect?: Rect; 49 | limits?: Limits; 50 | easing?: bezier.EasingFunction; 51 | 52 | constructor(options: ElementConstructorOptions) { 53 | this.el = options.el; 54 | this.props = options.props; 55 | this.scrollAxis = options.scrollAxis; 56 | this.disabledParallaxController = 57 | options.disabledParallaxController || false; 58 | this.id = createId(); 59 | this.effects = parseElementTransitionEffects(this.props, this.scrollAxis); 60 | this.isInView = null; 61 | this.progress = 0; 62 | 63 | this._setElementEasing(options.props.easing); 64 | 65 | setWillChangeStyles(options.el, this.effects); 66 | } 67 | 68 | updateProps(nextProps: ParallaxElementConfig) { 69 | this.props = { ...this.props, ...nextProps }; 70 | this.effects = parseElementTransitionEffects(nextProps, this.scrollAxis); 71 | this._setElementEasing(nextProps.easing); 72 | 73 | return this; 74 | } 75 | 76 | setCachedAttributes(view: View, scroll: Scroll): Element { 77 | // NOTE: Must reset styles before getting the rect, as it might impact the natural position 78 | resetStyles(this); 79 | 80 | this.rect = new Rect({ 81 | el: this.props.targetElement || this.el, 82 | rootMargin: this.props.rootMargin, 83 | view, 84 | }); 85 | 86 | const shouldScaleTranslateEffects = getShouldScaleTranslateEffects( 87 | this.props, 88 | this.effects, 89 | this.scrollAxis 90 | ); 91 | 92 | if ( 93 | typeof this.props.startScroll === 'number' && 94 | typeof this.props.endScroll === 'number' 95 | ) { 96 | this.limits = new Limits({ 97 | startX: this.props.startScroll, 98 | startY: this.props.startScroll, 99 | endX: this.props.endScroll, 100 | endY: this.props.endScroll, 101 | }); 102 | 103 | // Undo the reset -- place it back at current position with styles 104 | this._setElementStyles(); 105 | 106 | return this; 107 | } 108 | 109 | if (shouldScaleTranslateEffects) { 110 | this.limits = createLimitsWithTranslationsForRelativeElements( 111 | this.rect, 112 | view, 113 | this.effects, 114 | scroll, 115 | this.scrollAxis, 116 | this.props.shouldAlwaysCompleteAnimation 117 | ); 118 | 119 | this.scaledEffects = scaleTranslateEffectsForSlowerScroll( 120 | this.effects, 121 | this.limits 122 | ); 123 | } else { 124 | this.limits = createLimitsForRelativeElements( 125 | this.rect, 126 | view, 127 | scroll, 128 | this.props.shouldAlwaysCompleteAnimation 129 | ); 130 | } 131 | 132 | // Undo the reset -- place it back at current position with styles 133 | this._setElementStyles(); 134 | 135 | return this; 136 | } 137 | 138 | _updateElementIsInView(nextIsInView: boolean) { 139 | // NOTE: Check if this is the first change to make sure onExit isn't called 140 | const isFirstChange = this.isInView === null; 141 | if (nextIsInView !== this.isInView) { 142 | if (nextIsInView) { 143 | this.props.onEnter && this.props.onEnter(this); 144 | } else if (!isFirstChange) { 145 | this._setFinalProgress(); 146 | this._setElementStyles(); 147 | this.props.onExit && this.props.onExit(this); 148 | } 149 | } 150 | this.isInView = nextIsInView; 151 | } 152 | 153 | _setFinalProgress() { 154 | const finalProgress = clamp(Math.round(this.progress), 0, 1); 155 | this._updateElementProgress(finalProgress); 156 | } 157 | 158 | _setElementStyles() { 159 | if (this.props.disabled || this.disabledParallaxController) return; 160 | const effects = this.scaledEffects || this.effects; 161 | setElementStyles(effects, this.progress, this.el); 162 | } 163 | 164 | _updateElementProgress(nextProgress: number) { 165 | this.progress = nextProgress; 166 | this.props.onProgressChange && this.props.onProgressChange(this.progress); 167 | this.props.onChange && this.props.onChange(this); 168 | } 169 | 170 | _setElementEasing(easing?: EasingParam): void { 171 | this.easing = createEasingFunction(easing); 172 | } 173 | 174 | updateElementOptions(options: ParallaxControllerConstructorOptions) { 175 | this.scrollAxis = options.scrollAxis; 176 | this.disabledParallaxController = 177 | options.disabledParallaxController || false; 178 | } 179 | 180 | updatePosition(scroll: Scroll): Element { 181 | if (!this.limits) return this; 182 | 183 | const isVertical = this.scrollAxis === ScrollAxis.vertical; 184 | const isFirstChange = this.isInView === null; 185 | // based on scroll axis 186 | const start = isVertical ? this.limits.startY : this.limits.startX; 187 | const end = isVertical ? this.limits.endY : this.limits.endX; 188 | const total = isVertical ? this.limits.totalY : this.limits.totalX; 189 | const s = isVertical ? scroll.y : scroll.x; 190 | 191 | // check if in view 192 | const nextIsInView = isElementInView(start, end, s); 193 | this._updateElementIsInView(nextIsInView); 194 | 195 | // set the progress if in view or this is the first change 196 | if (nextIsInView) { 197 | const nextProgress = getProgressAmount(start, total, s, this.easing); 198 | this._updateElementProgress(nextProgress); 199 | this._setElementStyles(); 200 | } else if (isFirstChange) { 201 | // NOTE: this._updateElementProgress -- dont use this because it will trigger onChange 202 | this.progress = clamp( 203 | Math.round(getProgressAmount(start, total, s, this.easing)), 204 | 0, 205 | 1 206 | ); 207 | this._setElementStyles(); 208 | } 209 | 210 | return this; 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /src/classes/Limits.test.ts: -------------------------------------------------------------------------------- 1 | import { Limits } from './Limits'; 2 | 3 | describe('Limits', () => { 4 | test(`sets properties from constructor`, () => { 5 | const limits = new Limits({ 6 | startX: 100, 7 | startY: 300, 8 | endX: 600, 9 | endY: 900, 10 | }); 11 | expect(limits.startX).toBe(100); 12 | expect(limits.startY).toBe(300); 13 | expect(limits.endX).toBe(600); 14 | expect(limits.endY).toBe(900); 15 | expect(limits.totalX).toBe(500); 16 | expect(limits.totalY).toBe(600); 17 | expect(limits.startMultiplierX).toBe(1); 18 | expect(limits.endMultiplierX).toBe(1); 19 | expect(limits.startMultiplierY).toBe(1); 20 | expect(limits.endMultiplierY).toBe(1); 21 | }); 22 | 23 | test(`sets start and end multipliers when provided`, () => { 24 | const limits = new Limits({ 25 | startX: 0, 26 | startY: 0, 27 | endX: 0, 28 | endY: 0, 29 | startMultiplierX: 1.1, 30 | endMultiplierX: 1.2, 31 | startMultiplierY: 1.3, 32 | endMultiplierY: 1.4, 33 | }); 34 | expect(limits.startX).toBe(0); 35 | expect(limits.startY).toBe(0); 36 | expect(limits.endX).toBe(0); 37 | expect(limits.endY).toBe(0); 38 | expect(limits.totalX).toBe(0); 39 | expect(limits.totalY).toBe(0); 40 | expect(limits.startMultiplierX).toBe(1.1); 41 | expect(limits.endMultiplierX).toBe(1.2); 42 | expect(limits.startMultiplierY).toBe(1.3); 43 | expect(limits.endMultiplierY).toBe(1.4); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /src/classes/Limits.ts: -------------------------------------------------------------------------------- 1 | export type LimitOptions = { 2 | startX: number; 3 | startY: number; 4 | endX: number; 5 | endY: number; 6 | startMultiplierX?: number; 7 | endMultiplierX?: number; 8 | startMultiplierY?: number; 9 | endMultiplierY?: number; 10 | }; 11 | 12 | export class Limits { 13 | startX: number; 14 | startY: number; 15 | endX: number; 16 | endY: number; 17 | totalX: number; 18 | totalY: number; 19 | startMultiplierX: number; 20 | endMultiplierX: number; 21 | startMultiplierY: number; 22 | endMultiplierY: number; 23 | 24 | constructor(properties: LimitOptions) { 25 | this.startX = properties.startX; 26 | this.startY = properties.startY; 27 | this.endX = properties.endX; 28 | this.endY = properties.endY; 29 | // Used to calculate the progress of the element 30 | this.totalX = this.endX - this.startX; 31 | this.totalY = this.endY - this.startY; 32 | 33 | // Used to scale translate effects 34 | this.startMultiplierX = properties.startMultiplierX || 1; 35 | this.endMultiplierX = properties.endMultiplierX || 1; 36 | this.startMultiplierY = properties.startMultiplierY || 1; 37 | this.endMultiplierY = properties.endMultiplierY || 1; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/classes/ParallaxController.test.ts: -------------------------------------------------------------------------------- 1 | import { ParallaxController } from './ParallaxController'; 2 | import { Element } from './Element'; 3 | import { Rect } from './Rect'; 4 | import { Limits } from './Limits'; 5 | import { CSSEffect, ScrollAxis } from '../types'; 6 | import * as elementStyles from '../helpers/elementStyles'; 7 | 8 | const addEventListener = window.addEventListener; 9 | const removeEventListener = window.removeEventListener; 10 | 11 | const OPTIONS = { 12 | el: document.createElement('div'), 13 | props: { 14 | disabled: false, 15 | translateX: [0, 0] as CSSEffect, 16 | translateY: [0, 0] as CSSEffect, 17 | }, 18 | }; 19 | 20 | describe('Expect the ParallaxController', () => { 21 | afterEach(() => { 22 | jest.restoreAllMocks(); 23 | window.addEventListener = addEventListener; 24 | window.removeEventListener = removeEventListener; 25 | }); 26 | 27 | describe('when init with disabled configuration', () => { 28 | it('to not add listeners when init', () => { 29 | window.addEventListener = jest.fn(); 30 | const controller = ParallaxController.init({ 31 | scrollAxis: ScrollAxis.vertical, 32 | disabled: true, 33 | }); 34 | // @ts-expect-error 35 | expect(window.addEventListener.mock.calls[0]).toEqual( 36 | expect.arrayContaining(['test', null, expect.any(Object)]) 37 | ); 38 | // @ts-expect-error 39 | expect(window.addEventListener.mock.calls[1]).toBeUndefined(); 40 | // @ts-expect-error 41 | expect(window.addEventListener.mock.calls[2]).toBeUndefined(); 42 | // @ts-expect-error 43 | expect(window.addEventListener.mock.calls[3]).toBeUndefined(); 44 | // @ts-expect-error 45 | expect(window.addEventListener.mock.calls[4]).toBeUndefined(); 46 | // @ts-expect-error 47 | expect(window.addEventListener.mock.calls[5]).toBeUndefined(); 48 | controller.destroy(); 49 | }); 50 | it('to create an element with the disabledParallaxController property', () => { 51 | const controller = ParallaxController.init({ 52 | scrollAxis: ScrollAxis.vertical, 53 | disabled: true, 54 | }); 55 | const element = controller.createElement(OPTIONS); 56 | expect(element.disabledParallaxController).toBe(true); 57 | controller.destroy(); 58 | }); 59 | }); 60 | 61 | it('to return an instance on init', () => { 62 | const controller = ParallaxController.init({ 63 | scrollAxis: ScrollAxis.vertical, 64 | }); 65 | expect(controller).toBeInstanceOf(ParallaxController); 66 | controller.destroy(); 67 | }); 68 | 69 | it('to add listeners when init', () => { 70 | window.addEventListener = jest.fn(); 71 | const controller = ParallaxController.init({ 72 | scrollAxis: ScrollAxis.vertical, 73 | }); 74 | // @ts-expect-error 75 | expect(window.addEventListener.mock.calls[0]).toEqual( 76 | expect.arrayContaining(['test', null, expect.any(Object)]) 77 | ); 78 | // @ts-expect-error 79 | expect(window.addEventListener.mock.calls[1]).toEqual( 80 | expect.arrayContaining(['scroll', expect.any(Function), false]) 81 | ); 82 | // @ts-expect-error 83 | expect(window.addEventListener.mock.calls[2]).toEqual( 84 | expect.arrayContaining(['resize', expect.any(Function), false]) 85 | ); 86 | // @ts-expect-error 87 | expect(window.addEventListener.mock.calls[3]).toEqual( 88 | expect.arrayContaining(['blur', expect.any(Function), false]) 89 | ); 90 | // @ts-expect-error 91 | expect(window.addEventListener.mock.calls[4]).toEqual( 92 | expect.arrayContaining(['focus', expect.any(Function), false]) 93 | ); 94 | // @ts-expect-error 95 | expect(window.addEventListener.mock.calls[5]).toEqual( 96 | expect.arrayContaining(['load', expect.any(Function), false]) 97 | ); 98 | controller.destroy(); 99 | }); 100 | 101 | describe('when disabling the controller', () => { 102 | it('to update the disabled property', () => { 103 | const controller = ParallaxController.init({ 104 | scrollAxis: ScrollAxis.vertical, 105 | }); 106 | controller.disableParallaxController(); 107 | expect(controller.disabled).toBe(true); 108 | controller.destroy(); 109 | }); 110 | 111 | it('to reset element styles', () => { 112 | const controller = ParallaxController.init({ 113 | scrollAxis: ScrollAxis.vertical, 114 | }); 115 | const element = controller.createElement(OPTIONS); 116 | jest.spyOn(elementStyles, 'resetStyles'); 117 | controller.disableParallaxController(); 118 | expect(elementStyles.resetStyles).toHaveBeenCalledWith(element); 119 | controller.destroy(); 120 | }); 121 | 122 | it('to remove listeners', () => { 123 | window.removeEventListener = jest.fn(); 124 | const controller = ParallaxController.init({ 125 | scrollAxis: ScrollAxis.vertical, 126 | }); 127 | controller.disableParallaxController(); 128 | // @ts-expect-error 129 | expect(window.removeEventListener.mock.calls[0]).toEqual( 130 | expect.arrayContaining(['test', null, expect.any(Object)]) 131 | ); 132 | // @ts-expect-error 133 | expect(window.removeEventListener.mock.calls[1]).toEqual( 134 | expect.arrayContaining(['scroll', expect.any(Function), false]) 135 | ); 136 | // @ts-expect-error 137 | expect(window.removeEventListener.mock.calls[2]).toEqual( 138 | expect.arrayContaining(['resize', expect.any(Function), false]) 139 | ); 140 | controller.destroy(); 141 | }); 142 | }); 143 | 144 | it('to add a resize observer', () => { 145 | const controller = ParallaxController.init({ 146 | scrollAxis: ScrollAxis.vertical, 147 | }); 148 | expect(global.ResizeObserver).toBeCalledWith(expect.any(Function)); 149 | controller.destroy(); 150 | }); 151 | 152 | it('to create an element and return it', () => { 153 | const controller = ParallaxController.init({ 154 | scrollAxis: ScrollAxis.vertical, 155 | }); 156 | const element = controller.createElement(OPTIONS); 157 | 158 | expect(element).toBeInstanceOf(Element); 159 | expect(element.limits).toBeInstanceOf(Limits); 160 | expect(element.rect).toBeInstanceOf(Rect); 161 | 162 | controller.destroy(); 163 | }); 164 | 165 | it('to add created elements into the controller', () => { 166 | const controller = ParallaxController.init({ 167 | scrollAxis: ScrollAxis.vertical, 168 | }); 169 | const element = controller.createElement(OPTIONS); 170 | const elements = controller.getElements(); 171 | 172 | expect(elements[0]).toEqual(element); 173 | controller.destroy(); 174 | }); 175 | 176 | it('to remove elements from the controller', () => { 177 | const controller = ParallaxController.init({ 178 | scrollAxis: ScrollAxis.vertical, 179 | }); 180 | const element = controller.createElement(OPTIONS); 181 | expect(controller.getElements()[0]).toEqual(element); 182 | 183 | controller.removeElementById(element.id); 184 | expect(controller.getElements()).toEqual([]); 185 | controller.destroy(); 186 | }); 187 | 188 | it("to throw if matching units aren't provided", () => { 189 | window.removeEventListener = jest.fn(); 190 | const controller = ParallaxController.init({ 191 | scrollAxis: ScrollAxis.vertical, 192 | }); 193 | 194 | const incorrectOffsets = { 195 | el: document.createElement('div'), 196 | props: { 197 | disabled: false, 198 | translateX: ['-10%', '100px'], 199 | translateY: [100, '50px'], 200 | }, 201 | }; 202 | // @ts-expect-error 203 | expect(() => controller.createElement(incorrectOffsets)).toThrowError( 204 | 'Must provide matching units for the min and max offset values of each axis.' 205 | ); 206 | 207 | controller.destroy(); 208 | }); 209 | 210 | it('to disable all elements when calling disableAllElements()', () => { 211 | jest.spyOn(console, 'warn').mockImplementation(() => {}); 212 | const controller = ParallaxController.init({ 213 | scrollAxis: ScrollAxis.vertical, 214 | }); 215 | const elements = Array.from({ length: 3 }, () => 216 | controller.createElement(OPTIONS) 217 | ); 218 | elements.forEach(element => { 219 | expect(element.props.disabled).toBe(false); 220 | }); 221 | controller.disableAllElements(); 222 | elements.forEach(element => { 223 | expect(element.props.disabled).toBe(true); 224 | }); 225 | expect(console.warn).toHaveBeenCalledWith( 226 | 'deprecated: use disableParallaxController() instead' 227 | ); 228 | }); 229 | 230 | it('to enable all elements when calling enableAllElements()', () => { 231 | jest.spyOn(console, 'warn').mockImplementation(() => {}); 232 | const controller = ParallaxController.init({ 233 | scrollAxis: ScrollAxis.vertical, 234 | }); 235 | const elements = Array.from({ length: 3 }, () => 236 | controller.createElement({ ...OPTIONS, props: { disabled: true } }) 237 | ); 238 | elements.forEach(element => { 239 | expect(element.props.disabled).toBe(true); 240 | }); 241 | controller.enableAllElements(); 242 | elements.forEach(element => { 243 | expect(element.props.disabled).toBe(false); 244 | }); 245 | expect(console.warn).toHaveBeenCalledWith( 246 | 'deprecated: use enableParallaxController() instead' 247 | ); 248 | }); 249 | 250 | it('to remove listeners when destroyed', () => { 251 | window.removeEventListener = jest.fn(); 252 | const controller = ParallaxController.init({ 253 | scrollAxis: ScrollAxis.vertical, 254 | }); 255 | // @ts-expect-error 256 | expect(window.removeEventListener.mock.calls[0]).toEqual( 257 | expect.arrayContaining(['test', null, expect.any(Object)]) 258 | ); 259 | 260 | controller.destroy(); 261 | // @ts-expect-error 262 | expect(window.removeEventListener.mock.calls[1]).toEqual( 263 | expect.arrayContaining(['scroll', expect.any(Function), false]) 264 | ); 265 | // @ts-expect-error 266 | expect(window.removeEventListener.mock.calls[2]).toEqual( 267 | expect.arrayContaining(['resize', expect.any(Function), false]) 268 | ); 269 | }); 270 | 271 | it('to disconnect the resize observer', () => { 272 | const controller = ParallaxController.init({ 273 | scrollAxis: ScrollAxis.vertical, 274 | }); 275 | controller.destroy(); 276 | expect(controller._resizeObserver?.disconnect).toBeCalledTimes(1); 277 | }); 278 | }); 279 | -------------------------------------------------------------------------------- /src/classes/ParallaxController.ts: -------------------------------------------------------------------------------- 1 | import { resetStyles } from '../helpers/elementStyles'; 2 | import { View } from './View'; 3 | import { Scroll } from './Scroll'; 4 | import { Element } from './Element'; 5 | import { testForPassiveScroll } from '../utils/testForPassiveScroll'; 6 | import { 7 | CreateElementOptions, 8 | ParallaxControllerOptions, 9 | ParallaxElementConfig, 10 | ScrollAxis, 11 | ValidScrollAxis, 12 | ViewElement, 13 | } from '../types'; 14 | 15 | /** 16 | * ------------------------------------------------------- 17 | * Parallax Controller 18 | * ------------------------------------------------------- 19 | * 20 | * The global controller for setting up and managing a scroll view of elements. 21 | * 22 | */ 23 | 24 | export class ParallaxController { 25 | disabled: boolean; 26 | elements: Element[]; 27 | scrollAxis: ValidScrollAxis; 28 | viewEl: ViewElement; 29 | scroll: Scroll; 30 | view: View; 31 | _hasScrollContainer: boolean; 32 | _ticking: boolean; 33 | _supportsPassive: boolean; 34 | _resizeObserver?: ResizeObserver; 35 | 36 | /** 37 | * Static method to instantiate the ParallaxController. 38 | * @returns {Class} ParallaxController 39 | */ 40 | static init(options: ParallaxControllerOptions): ParallaxController { 41 | const hasWindow = typeof window !== 'undefined'; 42 | 43 | if (!hasWindow) { 44 | throw new Error( 45 | 'Looks like ParallaxController.init() was called on the server. This method must be called on the client.' 46 | ); 47 | } 48 | 49 | return new ParallaxController(options); 50 | } 51 | 52 | constructor({ 53 | scrollAxis = ScrollAxis.vertical, 54 | scrollContainer, 55 | disabled = false, 56 | }: ParallaxControllerOptions) { 57 | this.disabled = disabled; 58 | this.scrollAxis = scrollAxis; 59 | // All parallax elements to be updated 60 | this.elements = []; 61 | 62 | this._hasScrollContainer = !!scrollContainer; 63 | this.viewEl = scrollContainer ?? window; 64 | 65 | // Scroll and View 66 | const [x, y] = this._getScrollPosition(); 67 | this.scroll = new Scroll(x, y); 68 | 69 | this.view = new View({ 70 | width: 0, 71 | height: 0, 72 | scrollWidth: 0, 73 | scrollHeight: 0, 74 | scrollContainer: this._hasScrollContainer ? scrollContainer : undefined, 75 | }); 76 | 77 | // Ticking 78 | this._ticking = false; 79 | 80 | // Passive support 81 | this._supportsPassive = testForPassiveScroll(); 82 | 83 | // Bind methods to class 84 | this._bindAllMethods(); 85 | 86 | // If this is initialized disabled, don't do anything below. 87 | if (this.disabled) return; 88 | 89 | this._addListeners(this.viewEl); 90 | this._addResizeObserver(); 91 | this._setViewSize(); 92 | } 93 | 94 | _bindAllMethods() { 95 | [ 96 | '_addListeners', 97 | '_removeListeners', 98 | '_getScrollPosition', 99 | '_handleScroll', 100 | '_handleUpdateCache', 101 | '_updateAllElements', 102 | '_updateElementPosition', 103 | '_setViewSize', 104 | '_addResizeObserver', 105 | '_checkIfViewHasChanged', 106 | '_getViewParams', 107 | 'getElements', 108 | 'createElement', 109 | 'removeElementById', 110 | 'resetElementStyles', 111 | 'updateElementPropsById', 112 | 'update', 113 | 'updateScrollContainer', 114 | 'destroy', 115 | ].forEach((method: string) => { 116 | // @ts-expect-error 117 | this[method] = this[method].bind(this); 118 | }); 119 | } 120 | 121 | _addListeners(el: ViewElement) { 122 | el.addEventListener( 123 | 'scroll', 124 | this._handleScroll, 125 | this._supportsPassive ? { passive: true } : false 126 | ); 127 | window.addEventListener('resize', this._handleUpdateCache, false); 128 | window.addEventListener('blur', this._handleUpdateCache, false); 129 | window.addEventListener('focus', this._handleUpdateCache, false); 130 | window.addEventListener('load', this._handleUpdateCache, false); 131 | } 132 | 133 | _removeListeners(el: ViewElement) { 134 | el.removeEventListener('scroll', this._handleScroll, false); 135 | window.removeEventListener('resize', this._handleUpdateCache, false); 136 | window.removeEventListener('blur', this._handleUpdateCache, false); 137 | window.removeEventListener('focus', this._handleUpdateCache, false); 138 | window.removeEventListener('load', this._handleUpdateCache, false); 139 | this._resizeObserver?.disconnect(); 140 | } 141 | 142 | _addResizeObserver() { 143 | try { 144 | const observedEl: HTMLElement = this._hasScrollContainer 145 | ? (this.viewEl as HTMLElement) 146 | : document.documentElement; 147 | this._resizeObserver = new ResizeObserver(() => this.update()); 148 | this._resizeObserver.observe(observedEl); 149 | } catch (e) { 150 | console.warn( 151 | 'Failed to create the resize observer in the ParallaxContoller' 152 | ); 153 | } 154 | } 155 | 156 | _getScrollPosition() { 157 | // Save current scroll 158 | // Supports IE 9 and up. 159 | const nx = this._hasScrollContainer 160 | ? // @ts-expect-error 161 | this.viewEl.scrollLeft 162 | : window.pageXOffset; 163 | const ny = this._hasScrollContainer 164 | ? // @ts-expect-error 165 | this.viewEl.scrollTop 166 | : window.pageYOffset; 167 | 168 | return [nx, ny]; 169 | } 170 | 171 | /** 172 | * Window scroll handler sets scroll position 173 | * and then calls '_updateAllElements()'. 174 | */ 175 | _handleScroll() { 176 | const [nx, ny] = this._getScrollPosition(); 177 | this.scroll.setScroll(nx, ny); 178 | 179 | // Only called if the last animation request has been 180 | // completed and there are parallax elements to update 181 | if (!this._ticking && this.elements?.length > 0) { 182 | this._ticking = true; 183 | // @ts-ignore 184 | window.requestAnimationFrame(this._updateAllElements); 185 | } 186 | } 187 | 188 | /** 189 | * Window resize handler. Sets the new window inner height 190 | * then updates parallax element attributes and positions. 191 | */ 192 | _handleUpdateCache() { 193 | this._setViewSize(); 194 | this._updateAllElements({ updateCache: true }); 195 | } 196 | 197 | /** 198 | * Update element positions. 199 | * Determines if the element is in view based on the cached 200 | * attributes, if so set the elements parallax styles. 201 | */ 202 | _updateAllElements({ updateCache }: { updateCache?: boolean } = {}) { 203 | if (this.elements) { 204 | this.elements.forEach(element => { 205 | if (updateCache) { 206 | element.setCachedAttributes(this.view, this.scroll); 207 | } 208 | this._updateElementPosition(element); 209 | }); 210 | } 211 | // reset ticking so more animations can be called 212 | this._ticking = false; 213 | } 214 | 215 | /** 216 | * Update element positions. 217 | * Determines if the element is in view based on the cached 218 | * attributes, if so set the elements parallax styles. 219 | */ 220 | _updateElementPosition(element: Element) { 221 | if (element.props.disabled || this.disabled) return; 222 | element.updatePosition(this.scroll); 223 | } 224 | 225 | /** 226 | * Gets the params to set in the View from the scroll container or the window 227 | */ 228 | _getViewParams(): { 229 | width: number; 230 | height: number; 231 | scrollHeight: number; 232 | scrollWidth: number; 233 | } { 234 | if (this._hasScrollContainer) { 235 | // @ts-expect-error 236 | const width = this.viewEl.offsetWidth; 237 | // @ts-expect-error 238 | const height = this.viewEl.offsetHeight; 239 | // @ts-expect-error 240 | const scrollHeight = this.viewEl.scrollHeight; 241 | // @ts-expect-error 242 | const scrollWidth = this.viewEl.scrollWidth; 243 | return this.view.setSize({ 244 | width, 245 | height, 246 | scrollHeight, 247 | scrollWidth, 248 | }); 249 | } 250 | 251 | const html = document.documentElement; 252 | const width = window.innerWidth || html.clientWidth; 253 | const height = window.innerHeight || html.clientHeight; 254 | const scrollHeight = html.scrollHeight; 255 | const scrollWidth = html.scrollWidth; 256 | 257 | return { width, height, scrollHeight, scrollWidth }; 258 | } 259 | 260 | /** 261 | * Cache the view attributes 262 | */ 263 | _setViewSize() { 264 | return this.view.setSize(this._getViewParams()); 265 | } 266 | 267 | /** 268 | * Checks if any of the cached attributes of the view have changed. 269 | * @returns boolean 270 | */ 271 | _checkIfViewHasChanged() { 272 | return this.view.hasChanged(this._getViewParams()); 273 | } 274 | 275 | /** 276 | * ------------------------------------------------------- 277 | * Public methods 278 | * ------------------------------------------------------- 279 | */ 280 | 281 | /** 282 | * Returns all the parallax elements in the controller 283 | */ 284 | getElements(): Element[] { 285 | return this.elements; 286 | } 287 | 288 | /** 289 | * Creates and returns new parallax element with provided options to be managed by the controller. 290 | */ 291 | createElement(options: CreateElementOptions): Element { 292 | const newElement = new Element({ 293 | ...options, 294 | scrollAxis: this.scrollAxis, 295 | disabledParallaxController: this.disabled, 296 | }); 297 | newElement.setCachedAttributes(this.view, this.scroll); 298 | this.elements = this.elements 299 | ? [...this.elements, newElement] 300 | : [newElement]; 301 | this._updateElementPosition(newElement); 302 | 303 | // NOTE: This checks if the view has changed then update the controller and all elements if it has 304 | // This shouldn't always be necessary with a resize observer watching the view element 305 | // but there seems to be cases where the resize observer does not catch and update. 306 | if (this._checkIfViewHasChanged()) { 307 | this.update(); 308 | } 309 | return newElement; 310 | } 311 | 312 | /** 313 | * Remove an element by id 314 | */ 315 | removeElementById(id: number) { 316 | if (!this.elements) return; 317 | this.elements = this.elements.filter(el => el.id !== id); 318 | } 319 | 320 | /** 321 | * Updates an existing parallax element object with new options. 322 | */ 323 | updateElementPropsById(id: number, props: ParallaxElementConfig): void { 324 | if (this.elements) { 325 | this.elements = this.elements.map(el => { 326 | if (el.id === id) { 327 | return el.updateProps(props); 328 | } 329 | return el; 330 | }); 331 | } 332 | 333 | this.update(); 334 | } 335 | 336 | /** 337 | * Remove a target elements parallax styles 338 | */ 339 | resetElementStyles(element: Element) { 340 | resetStyles(element); 341 | } 342 | 343 | /** 344 | * Updates all cached attributes on parallax elements. 345 | */ 346 | update() { 347 | // Save the latest scroll position because window.scroll 348 | // may be called and the handle scroll event may not be called. 349 | const [nx, ny] = this._getScrollPosition(); 350 | this.scroll.setScroll(nx, ny); 351 | 352 | this._setViewSize(); 353 | this._updateAllElements({ updateCache: true }); 354 | } 355 | /** 356 | * Updates the scroll container of the parallax controller 357 | */ 358 | updateScrollContainer(el: HTMLElement) { 359 | // remove existing listeners with current el first 360 | this._removeListeners(this.viewEl); 361 | 362 | this.viewEl = el; 363 | this._hasScrollContainer = !!el; 364 | this.view = new View({ 365 | width: 0, 366 | height: 0, 367 | scrollWidth: 0, 368 | scrollHeight: 0, 369 | scrollContainer: el, 370 | }); 371 | this._setViewSize(); 372 | this._addListeners(this.viewEl); 373 | this._updateAllElements({ updateCache: true }); 374 | } 375 | 376 | disableParallaxController() { 377 | this.disabled = true; 378 | // remove listeners 379 | this._removeListeners(this.viewEl); 380 | // reset all styles 381 | if (this.elements) { 382 | this.elements.forEach(element => resetStyles(element)); 383 | } 384 | } 385 | 386 | enableParallaxController() { 387 | this.disabled = false; 388 | if (this.elements) { 389 | this.elements.forEach(element => 390 | element.updateElementOptions({ 391 | disabledParallaxController: false, 392 | scrollAxis: this.scrollAxis, 393 | }) 394 | ); 395 | } 396 | // add back listeners 397 | this._addListeners(this.viewEl); 398 | this._addResizeObserver(); 399 | this._setViewSize(); 400 | } 401 | 402 | /** 403 | * Disable all parallax elements 404 | */ 405 | disableAllElements() { 406 | console.warn('deprecated: use disableParallaxController() instead'); 407 | if (this.elements) { 408 | this.elements = this.elements.map(el => { 409 | return el.updateProps({ disabled: true }); 410 | }); 411 | } 412 | this.update(); 413 | } 414 | 415 | /** 416 | * Enable all parallax elements 417 | */ 418 | enableAllElements() { 419 | console.warn('deprecated: use enableParallaxController() instead'); 420 | if (this.elements) { 421 | this.elements = this.elements.map(el => { 422 | return el.updateProps({ disabled: false }); 423 | }); 424 | } 425 | this.update(); 426 | } 427 | 428 | /** 429 | * Removes all listeners and resets all styles on managed elements. 430 | */ 431 | destroy() { 432 | this._removeListeners(this.viewEl); 433 | if (this.elements) { 434 | this.elements.forEach(element => resetStyles(element)); 435 | } 436 | // @ts-expect-error 437 | this.elements = undefined; 438 | } 439 | } 440 | -------------------------------------------------------------------------------- /src/classes/Rect.test.ts: -------------------------------------------------------------------------------- 1 | import { Rect } from './Rect'; 2 | import { createElementMock } from '../testUtils/createElementMock'; 3 | import { View } from './View'; 4 | 5 | const DEFAULT_VIEW = new View({ 6 | width: 1000, 7 | height: 1000, 8 | scrollHeight: 3000, 9 | scrollWidth: 1000, 10 | }); 11 | 12 | describe('Rect', () => { 13 | test(`sets bounds based on root margin when provided`, () => { 14 | const rect = new Rect({ 15 | view: DEFAULT_VIEW, 16 | el: createElementMock( 17 | { offsetWidth: 100, offsetHeight: 100 }, 18 | { 19 | getBoundingClientRect: () => ({ 20 | top: 500, 21 | left: 200, 22 | bottom: 600, 23 | right: 300, 24 | }), 25 | } 26 | ), 27 | rootMargin: { 28 | top: 10, 29 | left: 20, 30 | right: 30, 31 | bottom: 40, 32 | }, 33 | }); 34 | 35 | expect(rect.top).toBe(490); 36 | expect(rect.left).toBe(180); 37 | expect(rect.right).toBe(330); 38 | expect(rect.bottom).toBe(640); 39 | }); 40 | 41 | test(`caches the bounding rect`, () => { 42 | const rect = new Rect({ 43 | view: DEFAULT_VIEW, 44 | el: createElementMock( 45 | { offsetWidth: 200, offsetHeight: 100 }, 46 | { 47 | getBoundingClientRect: () => ({ 48 | top: 500, 49 | left: 200, 50 | bottom: 600, 51 | right: 300, 52 | }), 53 | } 54 | ), 55 | }); 56 | 57 | expect(rect.width).toBe(200); 58 | expect(rect.height).toBe(100); 59 | expect(rect.top).toBe(500); 60 | expect(rect.left).toBe(200); 61 | expect(rect.bottom).toBe(600); 62 | expect(rect.right).toBe(300); 63 | }); 64 | 65 | test(`caches the bounding rect with scrollContainer`, () => { 66 | const rect = new Rect({ 67 | view: new View({ 68 | width: 2000, 69 | height: 1000, 70 | scrollWidth: 2000, 71 | scrollHeight: 2000, 72 | scrollContainer: createElementMock( 73 | { offsetWidth: 500, offsetHeight: 500 }, 74 | { 75 | getBoundingClientRect: () => ({ 76 | top: 100, 77 | left: 100, 78 | bottom: 600, 79 | right: 600, 80 | }), 81 | } 82 | ), 83 | }), 84 | el: createElementMock( 85 | { offsetWidth: 100, offsetHeight: 100 }, 86 | { 87 | getBoundingClientRect: () => ({ 88 | top: 500, 89 | left: 200, 90 | bottom: 600, 91 | right: 300, 92 | }), 93 | } 94 | ), 95 | }); 96 | 97 | expect(rect.height).toBe(100); 98 | expect(rect.width).toBe(100); 99 | expect(rect.left).toBe(100); 100 | expect(rect.right).toBe(200); 101 | expect(rect.top).toBe(400); 102 | expect(rect.bottom).toBe(500); 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /src/classes/Rect.ts: -------------------------------------------------------------------------------- 1 | import { View } from './View'; 2 | import { RootMarginShape } from '../types'; 3 | 4 | export class Rect { 5 | height: number; 6 | width: number; 7 | left: number; 8 | right: number; 9 | top: number; 10 | bottom: number; 11 | 12 | constructor(options: { 13 | el: HTMLElement; 14 | view: View; 15 | rootMargin?: RootMarginShape; 16 | }) { 17 | let rect = options.el.getBoundingClientRect(); 18 | 19 | // rect is based on viewport -- must adjust for relative scroll container 20 | if (options.view.scrollContainer) { 21 | const scrollRect = options.view.scrollContainer.getBoundingClientRect(); 22 | rect = { 23 | ...rect, 24 | top: rect.top - scrollRect.top, 25 | right: rect.right - scrollRect.left, 26 | bottom: rect.bottom - scrollRect.top, 27 | left: rect.left - scrollRect.left, 28 | }; 29 | } 30 | this.height = options.el.offsetHeight; 31 | this.width = options.el.offsetWidth; 32 | this.left = rect.left; 33 | this.right = rect.right; 34 | this.top = rect.top; 35 | this.bottom = rect.bottom; 36 | 37 | if (options.rootMargin) { 38 | this._setRectWithRootMargin(options.rootMargin); 39 | } 40 | } 41 | 42 | /** 43 | * Apply root margin to all properties 44 | */ 45 | _setRectWithRootMargin(rootMargin: RootMarginShape) { 46 | let totalRootY = rootMargin.top + rootMargin.bottom; 47 | let totalRootX = rootMargin.left + rootMargin.right; 48 | this.top -= rootMargin.top; 49 | this.right += rootMargin.right; 50 | this.bottom += rootMargin.bottom; 51 | this.left -= rootMargin.left; 52 | this.height += totalRootY; 53 | this.width += totalRootX; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/classes/Scroll.test.ts: -------------------------------------------------------------------------------- 1 | import { Scroll } from './Scroll'; 2 | 3 | describe('Expect the Scroll class', () => { 4 | it('to construct', () => { 5 | const scroll = new Scroll(10, 50); 6 | expect(scroll.x).toBe(10); 7 | expect(scroll.y).toBe(50); 8 | expect(scroll.dx).toBe(0); 9 | expect(scroll.dy).toBe(0); 10 | }); 11 | 12 | it('to set scroll and return the instance', () => { 13 | const scroll = new Scroll(9, 8); 14 | const instance = scroll.setScroll(10, 12); 15 | expect(instance.x).toBe(10); 16 | expect(instance.y).toBe(12); 17 | expect(instance).toBeInstanceOf(Scroll); 18 | }); 19 | 20 | it('to set delta values from last scroll', () => { 21 | const scroll = new Scroll(9, 8); 22 | const instance = scroll.setScroll(10, 12); 23 | expect(scroll.dx).toBe(1); 24 | expect(scroll.dy).toBe(4); 25 | scroll.setScroll(0, 0); 26 | expect(scroll.dx).toBe(-10); 27 | expect(scroll.dy).toBe(-12); 28 | expect(instance).toBeInstanceOf(Scroll); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/classes/Scroll.ts: -------------------------------------------------------------------------------- 1 | export class Scroll { 2 | x: number; 3 | y: number; 4 | dx: number; 5 | dy: number; 6 | 7 | constructor(x: number, y: number) { 8 | this.x = x; 9 | this.y = y; 10 | this.dx = 0; 11 | this.dy = 0; 12 | } 13 | 14 | setScroll(x: number, y: number) { 15 | this.dx = x - this.x; 16 | this.dy = y - this.y; 17 | this.x = x; 18 | this.y = y; 19 | return this; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/classes/View.test.ts: -------------------------------------------------------------------------------- 1 | import { View } from './View'; 2 | 3 | describe('Expect the View class', () => { 4 | it('to construct', () => { 5 | const div = document.createElement('div'); 6 | const view = new View({ 7 | width: 100, 8 | height: 150, 9 | scrollWidth: 100, 10 | scrollHeight: 3000, 11 | scrollContainer: div, 12 | }); 13 | expect(view).toMatchObject({ 14 | width: 100, 15 | height: 150, 16 | scrollContainer: div, 17 | }); 18 | }); 19 | 20 | it('to set size return the instance', () => { 21 | const div = document.createElement('div'); 22 | const view = new View({ 23 | width: 100, 24 | height: 150, 25 | scrollWidth: 100, 26 | scrollHeight: 3000, 27 | scrollContainer: div, 28 | }); 29 | const instance = view.setSize({ 30 | width: 400, 31 | height: 250, 32 | scrollWidth: 400, 33 | scrollHeight: 4000, 34 | }); 35 | expect(instance).toMatchObject({ 36 | width: 400, 37 | height: 250, 38 | scrollWidth: 400, 39 | scrollHeight: 4000, 40 | scrollContainer: div, 41 | }); 42 | expect(instance).toBeInstanceOf(View); 43 | }); 44 | 45 | it('to return if updates are needed based on params', () => { 46 | const div = document.createElement('div'); 47 | const view = new View({ 48 | width: 100, 49 | height: 150, 50 | scrollWidth: 100, 51 | scrollHeight: 3000, 52 | scrollContainer: div, 53 | }); 54 | const params = { 55 | width: 400, 56 | height: 250, 57 | scrollWidth: 400, 58 | scrollHeight: 4000, 59 | }; 60 | 61 | expect(view.hasChanged(params)).toBe(true); 62 | 63 | view.setSize(params); 64 | expect(view.hasChanged(params)).toBe(false); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /src/classes/View.ts: -------------------------------------------------------------------------------- 1 | export type ViewSizeParams = { 2 | width: number; 3 | height: number; 4 | scrollHeight: number; 5 | scrollWidth: number; 6 | }; 7 | export class View { 8 | scrollContainer: HTMLElement | undefined; 9 | width: number; 10 | height: number; 11 | scrollHeight: number; 12 | scrollWidth: number; 13 | 14 | constructor(config: { 15 | width: number; 16 | height: number; 17 | scrollHeight: number; 18 | scrollWidth: number; 19 | scrollContainer?: HTMLElement; 20 | }) { 21 | this.scrollContainer = config.scrollContainer; 22 | this.width = config.width; 23 | this.height = config.height; 24 | this.scrollHeight = config.scrollHeight; 25 | this.scrollWidth = config.scrollWidth; 26 | } 27 | 28 | hasChanged(params: ViewSizeParams) { 29 | if ( 30 | params.width !== this.width || 31 | params.height !== this.height || 32 | params.scrollWidth !== this.scrollWidth || 33 | params.scrollHeight !== this.scrollHeight 34 | ) { 35 | return true; 36 | } 37 | return false; 38 | } 39 | 40 | setSize(params: ViewSizeParams) { 41 | this.width = params.width; 42 | this.height = params.height; 43 | this.scrollHeight = params.scrollHeight; 44 | this.scrollWidth = params.scrollWidth; 45 | return this; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import { ValidEasingPresets } from './types'; 2 | 3 | export type EasingPreset = { [key in ValidEasingPresets]: number[] }; 4 | 5 | export const easingPresets: EasingPreset = { 6 | ease: [0.25, 0.1, 0.25, 1.0], 7 | easeIn: [0.42, 0.0, 1.0, 1.0], 8 | easeOut: [0.0, 0.0, 0.58, 1.0], 9 | easeInOut: [0.42, 0.0, 0.58, 1.0], 10 | /* Ease IN curves */ 11 | easeInQuad: [0.55, 0.085, 0.68, 0.53], 12 | easeInCubic: [0.55, 0.055, 0.675, 0.19], 13 | easeInQuart: [0.895, 0.03, 0.685, 0.22], 14 | easeInQuint: [0.755, 0.05, 0.855, 0.06], 15 | easeInSine: [0.47, 0.0, 0.745, 0.715], 16 | easeInExpo: [0.95, 0.05, 0.795, 0.035], 17 | easeInCirc: [0.6, 0.04, 0.98, 0.335], 18 | /* Ease Out Curves */ 19 | easeOutQuad: [0.25, 0.46, 0.45, 0.94], 20 | easeOutCubic: [0.215, 0.61, 0.355, 1.0], 21 | easeOutQuart: [0.165, 0.84, 0.44, 1.0], 22 | easeOutQuint: [0.23, 1.0, 0.32, 1.0], 23 | easeOutSine: [0.39, 0.575, 0.565, 1.0], 24 | easeOutExpo: [0.19, 1.0, 0.22, 1.0], 25 | easeOutCirc: [0.075, 0.82, 0.165, 1.0], 26 | /* Ease IN Out Curves */ 27 | easeInOutQuad: [0.455, 0.03, 0.515, 0.955], 28 | easeInOutCubic: [0.645, 0.045, 0.355, 1.0], 29 | easeInOutQuart: [0.77, 0.0, 0.175, 1.0], 30 | easeInOutQuint: [0.86, 0.0, 0.07, 1.0], 31 | easeInOutSine: [0.445, 0.05, 0.55, 0.95], 32 | easeInOutExpo: [1.0, 0.0, 0.0, 1.0], 33 | easeInOutCirc: [0.785, 0.135, 0.15, 0.86], 34 | /* Ease Bounce Curves */ 35 | easeInBack: [0.6, -0.28, 0.735, 0.045], 36 | easeOutBack: [0.175, 0.885, 0.32, 1.275], 37 | easeInOutBack: [0.68, -0.55, 0.265, 1.55], 38 | }; 39 | -------------------------------------------------------------------------------- /src/globals.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The **ResizeObserver** interface reports changes to the dimensions of an 3 | * [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element)'s content 4 | * or border box, or the bounding box of an 5 | * [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement). 6 | * 7 | * > **Note**: The content box is the box in which content can be placed, 8 | * > meaning the border box minus the padding and border width. The border box 9 | * > encompasses the content, padding, and border. See 10 | * > [The box model](https://developer.mozilla.org/en-US/docs/Learn/CSS/Building_blocks/The_box_model) 11 | * > for further explanation. 12 | * 13 | * `ResizeObserver` avoids infinite callback loops and cyclic dependencies that 14 | * are often created when resizing via a callback function. It does this by only 15 | * processing elements deeper in the DOM in subsequent frames. Implementations 16 | * should, if they follow the specification, invoke resize events before paint 17 | * and after layout. 18 | * 19 | * @see https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver 20 | */ 21 | declare class ResizeObserver { 22 | /** 23 | * The **ResizeObserver** constructor creates a new `ResizeObserver` object, 24 | * which can be used to report changes to the content or border box of an 25 | * `Element` or the bounding box of an `SVGElement`. 26 | * 27 | * @example 28 | * var ResizeObserver = new ResizeObserver(callback) 29 | * 30 | * @param callback 31 | * The function called whenever an observed resize occurs. The function is 32 | * called with two parameters: 33 | * * **entries** 34 | * An array of 35 | * [ResizeObserverEntry](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverEntry) 36 | * objects that can be used to access the new dimensions of the element 37 | * after each change. 38 | * * **observer** 39 | * A reference to the `ResizeObserver` itself, so it will definitely be 40 | * accessible from inside the callback, should you need it. This could be 41 | * used for example to automatically unobserve the observer when a certain 42 | * condition is reached, but you can omit it if you don't need it. 43 | * 44 | * The callback will generally follow a pattern along the lines of: 45 | * ```js 46 | * function(entries, observer) { 47 | * for (let entry of entries) { 48 | * // Do something to each entry 49 | * // and possibly something to the observer itself 50 | * } 51 | * } 52 | * ``` 53 | * 54 | * The following snippet is taken from the 55 | * [resize-observer-text.html](https://mdn.github.io/dom-examples/resize-observer/resize-observer-text.html) 56 | * ([see source](https://github.com/mdn/dom-examples/blob/master/resize-observer/resize-observer-text.html)) 57 | * example: 58 | * @example 59 | * const resizeObserver = new ResizeObserver(entries => { 60 | * for (let entry of entries) { 61 | * if(entry.contentBoxSize) { 62 | * h1Elem.style.fontSize = Math.max(1.5, entry.contentBoxSize.inlineSize/200) + 'rem'; 63 | * pElem.style.fontSize = Math.max(1, entry.contentBoxSize.inlineSize/600) + 'rem'; 64 | * } else { 65 | * h1Elem.style.fontSize = Math.max(1.5, entry.contentRect.width/200) + 'rem'; 66 | * pElem.style.fontSize = Math.max(1, entry.contentRect.width/600) + 'rem'; 67 | * } 68 | * } 69 | * }); 70 | * 71 | * resizeObserver.observe(divElem); 72 | */ 73 | constructor(callback: ResizeObserverCallback); 74 | 75 | /** 76 | * The **disconnect()** method of the 77 | * [ResizeObserver](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) 78 | * interface unobserves all observed 79 | * [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element) or 80 | * [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement) 81 | * targets. 82 | */ 83 | disconnect: () => void; 84 | 85 | /** 86 | * The `observe()` method of the 87 | * [ResizeObserver](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) 88 | * interface starts observing the specified 89 | * [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element) or 90 | * [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement). 91 | * 92 | * @example 93 | * resizeObserver.observe(target, options); 94 | * 95 | * @param target 96 | * A reference to an 97 | * [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element) or 98 | * [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement) 99 | * to be observed. 100 | * 101 | * @param options 102 | * An options object allowing you to set options for the observation. 103 | * Currently this only has one possible option that can be set. 104 | */ 105 | observe: (target: Element, options?: ResizeObserverObserveOptions) => void; 106 | 107 | /** 108 | * The **unobserve()** method of the 109 | * [ResizeObserver](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) 110 | * interface ends the observing of a specified 111 | * [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element) or 112 | * [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement). 113 | */ 114 | unobserve: (target: Element) => void; 115 | } 116 | 117 | interface ResizeObserverObserveOptions { 118 | /** 119 | * Sets which box model the observer will observe changes to. Possible values 120 | * are `content-box` (the default), and `border-box`. 121 | * 122 | * @default "content-box" 123 | */ 124 | box?: 'content-box' | 'border-box'; 125 | } 126 | 127 | /** 128 | * The function called whenever an observed resize occurs. The function is 129 | * called with two parameters: 130 | * 131 | * @param entries 132 | * An array of 133 | * [ResizeObserverEntry](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverEntry) 134 | * objects that can be used to access the new dimensions of the element after 135 | * each change. 136 | * 137 | * @param observer 138 | * A reference to the `ResizeObserver` itself, so it will definitely be 139 | * accessible from inside the callback, should you need it. This could be used 140 | * for example to automatically unobserve the observer when a certain condition 141 | * is reached, but you can omit it if you don't need it. 142 | * 143 | * The callback will generally follow a pattern along the lines of: 144 | * @example 145 | * function(entries, observer) { 146 | * for (let entry of entries) { 147 | * // Do something to each entry 148 | * // and possibly something to the observer itself 149 | * } 150 | * } 151 | * 152 | * @example 153 | * const resizeObserver = new ResizeObserver(entries => { 154 | * for (let entry of entries) { 155 | * if(entry.contentBoxSize) { 156 | * h1Elem.style.fontSize = Math.max(1.5, entry.contentBoxSize.inlineSize/200) + 'rem'; 157 | * pElem.style.fontSize = Math.max(1, entry.contentBoxSize.inlineSize/600) + 'rem'; 158 | * } else { 159 | * h1Elem.style.fontSize = Math.max(1.5, entry.contentRect.width/200) + 'rem'; 160 | * pElem.style.fontSize = Math.max(1, entry.contentRect.width/600) + 'rem'; 161 | * } 162 | * } 163 | * }); 164 | * 165 | * resizeObserver.observe(divElem); 166 | */ 167 | type ResizeObserverCallback = ( 168 | entries: ResizeObserverEntry[], 169 | observer: ResizeObserver 170 | ) => void; 171 | 172 | /** 173 | * The **ResizeObserverEntry** interface represents the object passed to the 174 | * [ResizeObserver()](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver/ResizeObserver) 175 | * constructor's callback function, which allows you to access the new 176 | * dimensions of the 177 | * [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element) or 178 | * [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement) 179 | * being observed. 180 | */ 181 | interface ResizeObserverEntry { 182 | /** 183 | * An object containing the new border box size of the observed element when 184 | * the callback is run. 185 | */ 186 | readonly borderBoxSize: ResizeObserverEntryBoxSize; 187 | 188 | /** 189 | * An object containing the new content box size of the observed element when 190 | * the callback is run. 191 | */ 192 | readonly contentBoxSize: ResizeObserverEntryBoxSize; 193 | 194 | /** 195 | * A [DOMRectReadOnly](https://developer.mozilla.org/en-US/docs/Web/API/DOMRectReadOnly) 196 | * object containing the new size of the observed element when the callback is 197 | * run. Note that this is better supported than the above two properties, but 198 | * it is left over from an earlier implementation of the Resize Observer API, 199 | * is still included in the spec for web compat reasons, and may be deprecated 200 | * in future versions. 201 | */ 202 | // node_modules/typescript/lib/lib.dom.d.ts 203 | readonly contentRect: DOMRectReadOnly; 204 | 205 | /** 206 | * A reference to the 207 | * [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element) or 208 | * [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement) 209 | * being observed. 210 | */ 211 | readonly target: Element; 212 | } 213 | 214 | /** 215 | * The **borderBoxSize** read-only property of the 216 | * [ResizeObserverEntry](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverEntry) 217 | * interface returns an object containing the new border box size of the 218 | * observed element when the callback is run. 219 | */ 220 | interface ResizeObserverEntryBoxSize { 221 | /** 222 | * The length of the observed element's border box in the block dimension. For 223 | * boxes with a horizontal 224 | * [writing-mode](https://developer.mozilla.org/en-US/docs/Web/CSS/writing-mode), 225 | * this is the vertical dimension, or height; if the writing-mode is vertical, 226 | * this is the horizontal dimension, or width. 227 | */ 228 | blockSize: number; 229 | 230 | /** 231 | * The length of the observed element's border box in the inline dimension. 232 | * For boxes with a horizontal 233 | * [writing-mode](https://developer.mozilla.org/en-US/docs/Web/CSS/writing-mode), 234 | * this is the horizontal dimension, or width; if the writing-mode is 235 | * vertical, this is the vertical dimension, or height. 236 | */ 237 | inlineSize: number; 238 | } 239 | 240 | interface Window { 241 | ResizeObserver: typeof ResizeObserver; 242 | } 243 | -------------------------------------------------------------------------------- /src/helpers/clamp.ts: -------------------------------------------------------------------------------- 1 | export const clamp = (num: number, min: number, max: number) => 2 | Math.min(Math.max(num, min), max); 3 | -------------------------------------------------------------------------------- /src/helpers/createEasingFunction.ts: -------------------------------------------------------------------------------- 1 | import bezier, { EasingFunction } from 'bezier-easing'; 2 | import { ValidEasingPresets, EasingParams } from '../types'; 3 | import { easingPresets } from '../constants'; 4 | 5 | export function createEasingFunction( 6 | easing: ValidEasingPresets | EasingParams | undefined 7 | ): EasingFunction | undefined { 8 | if (Array.isArray(easing)) { 9 | return bezier(easing[0], easing[1], easing[2], easing[3]); 10 | } 11 | if ( 12 | typeof easing === 'string' && 13 | typeof easingPresets[easing] !== 'undefined' 14 | ) { 15 | const params: number[] = easingPresets[easing]; 16 | return bezier(params[0], params[1], params[2], params[3]); 17 | } 18 | return; 19 | } 20 | -------------------------------------------------------------------------------- /src/helpers/createLimitsForRelativeElements.test.ts: -------------------------------------------------------------------------------- 1 | import { Scroll, View } from '..'; 2 | import { Rect } from '../classes/Rect'; 3 | import { createElementMock } from '../testUtils/createElementMock'; 4 | import { createLimitsForRelativeElements } from './createLimitsForRelativeElements'; 5 | 6 | const DEFAULT_VIEW = new View({ 7 | width: 768, 8 | height: 1024, 9 | scrollHeight: 2048, 10 | scrollWidth: 768, 11 | }); 12 | 13 | const DEFAULT_SCROLL = new Scroll(0, 0); 14 | 15 | const DEFAULT_RECT = new Rect({ 16 | el: createElementMock( 17 | { offsetWidth: 100, offsetHeight: 100 }, 18 | { 19 | getBoundingClientRect: () => ({ 20 | top: 500, 21 | left: 200, 22 | bottom: 700, 23 | right: 900, 24 | width: 100, 25 | height: 100, 26 | }), 27 | } 28 | ), 29 | view: DEFAULT_VIEW, 30 | }); 31 | 32 | describe('createLimitsForRelativeElements', () => { 33 | test(`returns expected Limits based on a relative element Rect within a View`, () => { 34 | const rect = DEFAULT_RECT; 35 | const view = DEFAULT_VIEW; 36 | const limit = createLimitsForRelativeElements(rect, view, DEFAULT_SCROLL); 37 | expect(limit.startX).toEqual(-568); 38 | expect(limit.startY).toEqual(-524); 39 | expect(limit.endX).toEqual(900); 40 | expect(limit.endY).toEqual(700); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/helpers/createLimitsForRelativeElements.ts: -------------------------------------------------------------------------------- 1 | import { Rect, Scroll, View } from '..'; 2 | import { Limits } from '../classes/Limits'; 3 | 4 | export function createLimitsForRelativeElements( 5 | rect: Rect, 6 | view: View, 7 | scroll: Scroll, 8 | shouldAlwaysCompleteAnimation?: boolean 9 | ): Limits { 10 | let startY = rect.top - view.height; 11 | let startX = rect.left - view.width; 12 | let endY = rect.bottom; 13 | let endX = rect.right; 14 | 15 | // add scroll 16 | startX += scroll.x; 17 | endX += scroll.x; 18 | startY += scroll.y; 19 | endY += scroll.y; 20 | 21 | if (shouldAlwaysCompleteAnimation) { 22 | if (scroll.y + rect.top < view.height) { 23 | startY = 0; 24 | } 25 | if (scroll.x + rect.left < view.width) { 26 | startX = 0; 27 | } 28 | if (endY > view.scrollHeight - view.height) { 29 | endY = view.scrollHeight - view.height; 30 | } 31 | if (endX > view.scrollWidth - view.width) { 32 | endX = view.scrollWidth - view.width; 33 | } 34 | } 35 | 36 | const limits = new Limits({ 37 | startX, 38 | startY, 39 | endX, 40 | endY, 41 | }); 42 | 43 | return limits; 44 | } 45 | -------------------------------------------------------------------------------- /src/helpers/createLimitsWithTranslationsForRelativeElements.test.ts: -------------------------------------------------------------------------------- 1 | import { createLimitsWithTranslationsForRelativeElements } from './createLimitsWithTranslationsForRelativeElements'; 2 | 3 | describe.skip.each([ 4 | [ 5 | { 6 | top: 500, 7 | left: 200, 8 | bottom: 700, 9 | right: 900, 10 | width: 700, 11 | height: 200, 12 | originTotalDistY: 300, 13 | originTotalDistX: 1700, 14 | }, 15 | { width: 1000, height: 100 }, 16 | { 17 | translateY: { start: 0, end: 0, unit: 'px', easing: undefined }, 18 | translateX: { start: 0, end: 0, unit: 'px', easing: undefined }, 19 | }, 20 | { 21 | // totalX: 1700, 22 | // totalY: 300, 23 | startY: 500, 24 | startX: 200, 25 | endY: 700, 26 | endX: 900, 27 | }, 28 | ], 29 | [ 30 | { 31 | top: 0, 32 | left: 0, 33 | bottom: 200, 34 | right: 200, 35 | width: 200, 36 | height: 200, 37 | originTotalDistY: 700, 38 | originTotalDistX: 700, 39 | }, 40 | { width: 500, height: 500 }, 41 | { 42 | translateY: { start: -10, end: 10, unit: '%', easing: undefined }, 43 | translateX: { start: 10, end: -10, unit: '%', easing: undefined }, 44 | }, 45 | { 46 | // totalX: 740, 47 | // totalY: 740, 48 | startY: -21.21212121212121, 49 | startX: 0, 50 | endY: 221.21212121212122, 51 | endX: 200, 52 | }, 53 | ], 54 | [ 55 | { 56 | height: 200, 57 | width: 200, 58 | left: 503.75, 59 | right: 703.75, 60 | top: 912.5, 61 | bottom: 1112.5, 62 | originTotalDistY: 875, 63 | originTotalDistX: 1005, 64 | }, 65 | { width: 805, height: 675 }, 66 | { 67 | translateY: { start: 50, end: -50, unit: '%', easing: undefined }, 68 | translateX: { start: 0, end: 0, unit: '%', easing: undefined }, 69 | }, 70 | { 71 | // totalX: 1005, 72 | // totalY: 1075, 73 | startY: 912.5, 74 | startX: 503.75, 75 | endY: 1112.5, 76 | endX: 703.75, 77 | }, 78 | ], 79 | [ 80 | { 81 | height: 200, 82 | width: 200, 83 | left: 668, 84 | right: 868, 85 | top: 912.5, 86 | bottom: 1112.5, 87 | originTotalDistY: 875, 88 | originTotalDistX: 1224, 89 | }, 90 | { width: 1024, height: 675 }, 91 | { 92 | translateY: { start: 50, end: -50, unit: '%', easing: undefined }, 93 | translateX: { start: 0, end: 0, unit: '%', easing: undefined }, 94 | }, 95 | { 96 | // totalX: 1224, 97 | // totalY: 1075, 98 | startY: 912.5, 99 | startX: 668, 100 | endY: 1112.5, 101 | endX: 868, 102 | }, 103 | ], 104 | [ 105 | { 106 | height: 200, 107 | width: 200, 108 | left: 156, 109 | right: 356, 110 | top: 912.5, 111 | bottom: 1112.5, 112 | originTotalDistY: 875, 113 | originTotalDistX: 1224, 114 | }, 115 | { width: 1024, height: 675 }, 116 | { 117 | translateY: { start: 0, end: 0, unit: '%', easing: undefined }, 118 | translateX: { start: -50, end: 50, unit: '%', easing: undefined }, 119 | }, 120 | { 121 | // totalX: 1424, 122 | // totalY: 875, 123 | startY: 912.5, 124 | startX: 36.46875, 125 | endY: 1112.5, 126 | endX: 475.53125, 127 | }, 128 | ], 129 | [ 130 | { 131 | height: 102, 132 | width: 103, 133 | left: 802.125, 134 | right: 904.515625, 135 | top: 9516.5, 136 | bottom: 9618.890625, 137 | originTotalDistY: 915, 138 | originTotalDistX: 1127, 139 | }, 140 | { width: 1024, height: 813 }, 141 | { 142 | translateY: { start: 50, end: -50, unit: '%', easing: undefined }, 143 | translateX: { start: 50, end: -50, unit: '%', easing: undefined }, 144 | }, 145 | { 146 | // totalX: 1230, 147 | // totalY: 1017, 148 | startY: 9516.5, 149 | startX: 802.125, 150 | endY: 9618.890625, 151 | endX: 904.515625, 152 | }, 153 | ], 154 | [ 155 | { 156 | height: 102, 157 | width: 102, 158 | left: 460.796875, 159 | right: 563.1875, 160 | top: 9810.890625, 161 | bottom: 9913.28125, 162 | originTotalDistY: 915, 163 | originTotalDistX: 1126, 164 | }, 165 | { width: 1024, height: 813 }, 166 | { 167 | translateY: { start: -50, end: 50, unit: '%', easing: undefined }, 168 | translateX: { start: -50, end: 50, unit: '%', easing: undefined }, 169 | }, 170 | { 171 | // totalX: 1228, 172 | // totalY: 1017, 173 | startY: 9753.492101014761, 174 | startX: 404.716796875, 175 | endY: 9970.679773985239, 176 | endX: 619.267578125, 177 | }, 178 | ], 179 | [ 180 | { 181 | height: 102, 182 | width: 103, 183 | left: 802.125, 184 | right: 904.515625, 185 | top: 9516.5, 186 | bottom: 9618.890625, 187 | originTotalDistY: 915, 188 | originTotalDistX: 1127, 189 | }, 190 | { width: 1024, height: 813 }, 191 | { 192 | translateY: { start: 50, end: -50, unit: '%', easing: undefined }, 193 | translateX: { start: 50, end: -50, unit: '%', easing: undefined }, 194 | }, 195 | { 196 | // totalX: 1230, 197 | // totalY: 1017, 198 | startY: 9516.5, 199 | startX: 802.125, 200 | endY: 9618.890625, 201 | endX: 904.515625, 202 | }, 203 | ], 204 | [ 205 | { 206 | height: 200, 207 | width: 200, 208 | left: 634.25, 209 | right: 834.25, 210 | top: 864.5, 211 | bottom: 1064.5, 212 | originTotalDistY: 843, 213 | originTotalDistX: 1179, 214 | }, 215 | { width: 979, height: 643 }, 216 | { 217 | translateY: { start: 85, end: -85, unit: 'px', easing: undefined }, 218 | translateX: { start: 0, end: 0, unit: '%', easing: undefined }, 219 | }, 220 | { 221 | // totalX: 1179, 222 | // totalY: 1013, 223 | startY: 864.5, 224 | startX: 634.25, 225 | endY: 1064.5, 226 | endX: 834.25, 227 | }, 228 | ], 229 | [ 230 | { 231 | height: 75, 232 | width: 75, 233 | left: 813.1875, 234 | right: 887.8125, 235 | top: 927.1875, 236 | bottom: 1001.8125, 237 | originTotalDistY: 718, 238 | originTotalDistX: 966, 239 | }, 240 | { width: 891, height: 643 }, 241 | { 242 | translateY: { start: -200, end: 125, unit: 'px', easing: undefined }, 243 | translateX: { start: 0, end: 0, unit: '%', easing: undefined }, 244 | }, 245 | { 246 | // totalX: 966, 247 | // totalY: 1043, 248 | startY: 561.7930979643766, 249 | startX: 813.1875, 250 | endY: 1230.1840012722646, 251 | endX: 887.8125, 252 | }, 253 | ], 254 | ])( 255 | 'createLimitsWithTranslationsForRelativeElements()', 256 | (rect: any, view: any, translate: any, expected) => { 257 | test(`returns expected bounds based on rect, offsets, and view`, () => { 258 | expect( 259 | // @ts-expect-error 260 | createLimitsWithTranslationsForRelativeElements(rect, view, translate) 261 | ).toEqual(expect.objectContaining(expected)); 262 | }); 263 | } 264 | ); 265 | -------------------------------------------------------------------------------- /src/helpers/createLimitsWithTranslationsForRelativeElements.ts: -------------------------------------------------------------------------------- 1 | import { ParsedValueEffect } from '../types'; 2 | import { Rect } from '../classes/Rect'; 3 | import { View } from '../classes/View'; 4 | import { Limits } from '../classes/Limits'; 5 | import { Scroll } from '../classes/Scroll'; 6 | 7 | import { getTranslateScalar } from './getTranslateScalar'; 8 | import { getStartEndValueInPx } from './getStartEndValueInPx'; 9 | import { ParallaxStartEndEffects, ScrollAxis, ValidScrollAxis } from '../types'; 10 | 11 | const DEFAULT_VALUE: ParsedValueEffect = { 12 | start: 0, 13 | end: 0, 14 | unit: '', 15 | }; 16 | 17 | export function createLimitsWithTranslationsForRelativeElements( 18 | rect: Rect, 19 | view: View, 20 | effects: ParallaxStartEndEffects, 21 | scroll: Scroll, 22 | scrollAxis: ValidScrollAxis, 23 | shouldAlwaysCompleteAnimation?: boolean 24 | ): Limits { 25 | // get start and end accounting for percent effects 26 | const translateX: ParsedValueEffect = effects.translateX || DEFAULT_VALUE; 27 | const translateY: ParsedValueEffect = effects.translateY || DEFAULT_VALUE; 28 | 29 | const { 30 | start: startTranslateXPx, 31 | end: endTranslateXPx, 32 | } = getStartEndValueInPx(translateX, rect.width); 33 | const { 34 | start: startTranslateYPx, 35 | end: endTranslateYPx, 36 | } = getStartEndValueInPx(translateY, rect.height); 37 | 38 | // default starting values 39 | let startY = rect.top - view.height; 40 | let startX = rect.left - view.width; 41 | let endY = rect.bottom; 42 | let endX = rect.right; 43 | 44 | let startMultiplierY = 1; 45 | let endMultiplierY = 1; 46 | if (scrollAxis === ScrollAxis.vertical) { 47 | startMultiplierY = getTranslateScalar( 48 | startTranslateYPx, 49 | endTranslateYPx, 50 | view.height + rect.height 51 | ); 52 | endMultiplierY = startMultiplierY; 53 | } 54 | let startMultiplierX = 1; 55 | let endMultiplierX = 1; 56 | if (scrollAxis === ScrollAxis.horizontal) { 57 | startMultiplierX = getTranslateScalar( 58 | startTranslateXPx, 59 | endTranslateXPx, 60 | view.width + rect.width 61 | ); 62 | endMultiplierX = startMultiplierX; 63 | } 64 | 65 | // Apply the scale to initial values 66 | if (startTranslateYPx < 0) { 67 | startY = startY + startTranslateYPx * startMultiplierY; 68 | } 69 | if (endTranslateYPx > 0) { 70 | endY = endY + endTranslateYPx * endMultiplierY; 71 | } 72 | if (startTranslateXPx < 0) { 73 | startX = startX + startTranslateXPx * startMultiplierX; 74 | } 75 | if (endTranslateXPx > 0) { 76 | endX = endX + endTranslateXPx * endMultiplierX; 77 | } 78 | 79 | // add scroll 80 | startX += scroll.x; 81 | endX += scroll.x; 82 | startY += scroll.y; 83 | endY += scroll.y; 84 | 85 | // NOTE: please refactor and isolate this :( 86 | if (shouldAlwaysCompleteAnimation) { 87 | const topBeginsInView = scroll.y + rect.top < view.height; 88 | const leftBeginsInView = scroll.x + rect.left < view.width; 89 | const bottomEndsInView = 90 | scroll.y + rect.bottom > view.scrollHeight - view.height; 91 | const rightEndsInView = 92 | scroll.x + rect.right > view.scrollWidth - view.height; 93 | 94 | if (topBeginsInView && bottomEndsInView) { 95 | startMultiplierY = 1; 96 | endMultiplierY = 1; 97 | startY = 0; 98 | endY = view.scrollHeight - view.height; 99 | } 100 | if (leftBeginsInView && rightEndsInView) { 101 | startMultiplierX = 1; 102 | endMultiplierX = 1; 103 | startX = 0; 104 | endX = view.scrollWidth - view.width; 105 | } 106 | 107 | if (!topBeginsInView && bottomEndsInView) { 108 | startY = rect.top - view.height + scroll.y; 109 | endY = view.scrollHeight - view.height; 110 | const totalDist = endY - startY; 111 | startMultiplierY = getTranslateScalar( 112 | startTranslateYPx, 113 | endTranslateYPx, 114 | totalDist 115 | ); 116 | endMultiplierY = 1; 117 | if (startTranslateYPx < 0) { 118 | startY = startY + startTranslateYPx * startMultiplierY; 119 | } 120 | } 121 | if (!leftBeginsInView && rightEndsInView) { 122 | startX = rect.left - view.width + scroll.x; 123 | endX = view.scrollWidth - view.width; 124 | const totalDist = endX - startX; 125 | startMultiplierX = getTranslateScalar( 126 | startTranslateXPx, 127 | endTranslateXPx, 128 | totalDist 129 | ); 130 | endMultiplierX = 1; 131 | if (startTranslateXPx < 0) { 132 | startX = startX + startTranslateXPx * startMultiplierX; 133 | } 134 | } 135 | 136 | if (topBeginsInView && !bottomEndsInView) { 137 | startY = 0; 138 | endY = rect.bottom + scroll.y; 139 | const totalDist = endY - startY; 140 | startMultiplierY = 1; 141 | endMultiplierY = getTranslateScalar( 142 | startTranslateYPx, 143 | endTranslateYPx, 144 | totalDist 145 | ); 146 | if (endTranslateYPx > 0) { 147 | endY = endY + endTranslateYPx * endMultiplierY; 148 | } 149 | } 150 | if (leftBeginsInView && !rightEndsInView) { 151 | startX = 0; 152 | endX = rect.right + scroll.x; 153 | const totalDist = endX - startX; 154 | startMultiplierX = 1; 155 | endMultiplierX = getTranslateScalar( 156 | startTranslateXPx, 157 | endTranslateXPx, 158 | totalDist 159 | ); 160 | if (endTranslateXPx > 0) { 161 | endX = endX + endTranslateXPx * endMultiplierX; 162 | } 163 | } 164 | } 165 | 166 | const limits = new Limits({ 167 | startX, 168 | startY, 169 | endX, 170 | endY, 171 | startMultiplierX, 172 | endMultiplierX, 173 | startMultiplierY, 174 | endMultiplierY, 175 | }); 176 | 177 | return limits; 178 | } 179 | -------------------------------------------------------------------------------- /src/helpers/elementStyles.test.ts: -------------------------------------------------------------------------------- 1 | import { EffectNumber, EffectString } from '../types'; 2 | import createNodeMock from '../testUtils/createNodeMock'; 3 | import { setElementStyles } from './elementStyles'; 4 | import { parseElementTransitionEffects } from './parseElementTransitionEffects'; 5 | import { ScrollAxis, ValidScrollAxis } from '..'; 6 | 7 | type Offset = string | number; 8 | 9 | function createTranslateEffects( 10 | x0: Offset, 11 | x1: Offset, 12 | y0: Offset, 13 | y1: Offset, 14 | scrollAxis: ValidScrollAxis = ScrollAxis.vertical 15 | ) { 16 | return parseElementTransitionEffects( 17 | { 18 | translateX: [x0, x1] as EffectNumber | EffectString, 19 | translateY: [y0, y1] as EffectNumber | EffectString, 20 | }, 21 | scrollAxis 22 | ); 23 | } 24 | 25 | function createEffect(v1: Offset, v2: Offset, key: string) { 26 | const effect = parseElementTransitionEffects( 27 | { 28 | [key]: [v1, v2], 29 | }, 30 | ScrollAxis.vertical 31 | ); 32 | return effect; 33 | } 34 | 35 | describe('setElementStyles', () => { 36 | test(`handles scale styles`, () => { 37 | const el = createNodeMock(); 38 | const offsets = { 39 | ...createEffect(0, 1, 'scale'), 40 | }; 41 | const progress = 0.5; 42 | // @ts-expect-error 43 | setElementStyles(offsets, progress, el); 44 | expect(el.style.transform).toBe(`scale(0.5)`); 45 | }); 46 | 47 | test(`handles opacity styles`, () => { 48 | const el = createNodeMock(); 49 | const offsets = { 50 | ...createEffect(0, 1, 'opacity'), 51 | }; 52 | const progress = 0.5; 53 | // @ts-expect-error 54 | setElementStyles(offsets, progress, el); 55 | expect(el.style.opacity).toBe(`0.5`); 56 | }); 57 | 58 | test(`exits early when inner element is not provided`, () => { 59 | const el = undefined; 60 | const offsets = { 61 | ...createEffect(0, 1, 'opacity'), 62 | }; 63 | const progress = 0.5; 64 | expect(() => { 65 | setElementStyles(offsets, progress, el); 66 | }).not.toThrowError(); 67 | }); 68 | }); 69 | 70 | describe.each([ 71 | [ 72 | createNodeMock(), 73 | { 74 | ...createEffect(-33, 100, 'translateX'), 75 | }, 76 | 0, 77 | `translateX(-33%)`, 78 | ], 79 | [ 80 | createNodeMock(), 81 | { 82 | ...createEffect(-33, 100, 'foo'), 83 | }, 84 | 0, 85 | ``, 86 | ], 87 | [ 88 | createNodeMock(), 89 | { 90 | ...createEffect('-33px', '33px', 'translateX'), 91 | ...createEffect('-0px', '50px', 'translateY'), 92 | ...createEffect('100deg', '0deg', 'rotateX'), 93 | }, 94 | 0.5, 95 | `translateX(0px)translateY(25px)rotateX(50deg)`, 96 | ], 97 | [ 98 | createNodeMock(), 99 | { 100 | ...createEffect('-33px', '33px', 'translateX'), 101 | ...createEffect('-0px', '50px', 'translateY'), 102 | ...createEffect('100deg', '0deg', 'rotateX'), 103 | ...createEffect('0deg', '180deg', 'rotateY'), 104 | ...createEffect('0deg', '360deg', 'rotateZ'), 105 | }, 106 | 1, 107 | `translateX(33px)translateY(50px)rotateX(0deg)rotateY(180deg)rotateZ(360deg)`, 108 | ], 109 | [ 110 | createNodeMock(), 111 | createTranslateEffects(0, 100, 0, 0), 112 | 1, 113 | `translateX(100%)translateY(0%)`, 114 | ], 115 | [ 116 | createNodeMock(), 117 | createTranslateEffects(0, 100, 0, 0), 118 | 2, 119 | `translateX(200%)translateY(0%)`, 120 | ], 121 | [ 122 | createNodeMock(), 123 | createTranslateEffects(0, 100, 0, 0), 124 | 0, 125 | `translateX(0%)translateY(0%)`, 126 | ], 127 | [ 128 | createNodeMock(), 129 | createTranslateEffects(100, 0, 0, 0), 130 | 0.5, 131 | `translateX(50%)translateY(0%)`, 132 | ], 133 | [ 134 | createNodeMock(), 135 | createTranslateEffects(100, -100, 100, -100), 136 | 0, 137 | `translateX(100%)translateY(100%)`, 138 | ], 139 | [ 140 | createNodeMock(), 141 | createTranslateEffects(100, -100, 100, -100), 142 | 0.5, 143 | `translateX(0%)translateY(0%)`, 144 | ], 145 | [ 146 | createNodeMock(), 147 | createTranslateEffects(100, -100, 100, -100), 148 | 1, 149 | `translateX(-100%)translateY(-100%)`, 150 | ], 151 | [ 152 | createNodeMock(), 153 | createTranslateEffects(100, -100, 100, -100), 154 | 2, 155 | `translateX(-300%)translateY(-300%)`, 156 | ], 157 | [ 158 | createNodeMock(), 159 | createTranslateEffects(100, -100, 100, -100), 160 | -1, 161 | `translateX(300%)translateY(300%)`, 162 | ], 163 | [ 164 | createNodeMock(), 165 | createTranslateEffects('0px', '100px', '100%', '50%'), 166 | 0, 167 | `translateX(0px)translateY(100%)`, 168 | ], 169 | [ 170 | createNodeMock(), 171 | createTranslateEffects('0px', '100px', '100%', '50%'), 172 | 0.5, 173 | `translateX(50px)translateY(75%)`, 174 | ], 175 | [ 176 | createNodeMock(), 177 | createTranslateEffects('-100px', '100px', '100%', '-200%'), 178 | 0.5, 179 | `translateX(0px)translateY(-50%)`, 180 | ], 181 | ])('.setElementStyles(%o, %o, %n)', (el, offsets, progress, expected) => { 182 | test(`sets element styles to: ${expected}%`, () => { 183 | // @ts-expect-error 184 | setElementStyles(offsets, progress, el); 185 | expect(el.style.transform).toBe(expected); 186 | }); 187 | }); 188 | -------------------------------------------------------------------------------- /src/helpers/elementStyles.ts: -------------------------------------------------------------------------------- 1 | import { Element } from '../classes/Element'; 2 | import { ParallaxStartEndEffects, ValidCSSEffects } from '../types'; 3 | import { scaleEffectByProgress } from './scaleEffectByProgress'; 4 | 5 | // Exclude opacity from transforms 6 | const TRANSFORM_EFFECTS = Object.values(ValidCSSEffects).filter( 7 | v => v !== 'opacity' 8 | ); 9 | 10 | export function setWillChangeStyles( 11 | el: HTMLElement, 12 | effects: ParallaxStartEndEffects 13 | ) { 14 | const keys = Object.keys(effects); 15 | const hasOpacity = keys.includes('opacity'); 16 | const willChange = `transform${hasOpacity ? ',opacity' : ''}`; 17 | el.style.willChange = willChange; 18 | } 19 | 20 | export function setElementStyles( 21 | effects: ParallaxStartEndEffects, 22 | progress: number, 23 | el?: HTMLElement 24 | ) { 25 | if (!el) return; 26 | const transform = getTransformStyles(effects, progress); 27 | const opacity = getOpacityStyles(effects, progress); 28 | el.style.transform = transform; 29 | el.style.opacity = opacity; 30 | } 31 | 32 | export function getOpacityStyles( 33 | effects: ParallaxStartEndEffects, 34 | progress: number 35 | ): string { 36 | const scaledOpacity = 37 | effects['opacity'] && scaleEffectByProgress(effects['opacity'], progress); 38 | 39 | if ( 40 | typeof scaledOpacity === 'undefined' || 41 | typeof scaledOpacity.value === 'undefined' || 42 | typeof scaledOpacity.unit === 'undefined' 43 | ) { 44 | return ''; 45 | } 46 | 47 | const styleStr = `${scaledOpacity.value}`; 48 | 49 | return styleStr; 50 | } 51 | 52 | export function getTransformStyles( 53 | effects: ParallaxStartEndEffects, 54 | progress: number 55 | ): string { 56 | const transform: string = TRANSFORM_EFFECTS.reduce((acc, key: string) => { 57 | const scaledEffect = 58 | // @ts-expect-error 59 | effects[key] && scaleEffectByProgress(effects[key], progress); 60 | 61 | if ( 62 | typeof scaledEffect === 'undefined' || 63 | typeof scaledEffect.value === 'undefined' || 64 | typeof scaledEffect.unit === 'undefined' 65 | ) { 66 | return acc; 67 | } 68 | 69 | const styleStr = `${key}(${scaledEffect.value}${scaledEffect.unit})`; 70 | 71 | return acc + styleStr; 72 | }, ''); 73 | 74 | return transform; 75 | } 76 | 77 | /** 78 | * Takes a parallax element and removes parallax offset styles. 79 | * @param {object} element 80 | */ 81 | export function resetStyles(element: Element) { 82 | const el = element.el; 83 | if (!el) return; 84 | el.style.transform = ''; 85 | el.style.opacity = ''; 86 | } 87 | -------------------------------------------------------------------------------- /src/helpers/getProgressAmount.test.ts: -------------------------------------------------------------------------------- 1 | import { getProgressAmount } from './getProgressAmount'; 2 | 3 | describe.each([ 4 | [0, 600, 300, 0.5], 5 | [0, 600, 600, 1], 6 | [0, 600, 0, 0], 7 | [0, 600, 150, 0.25], 8 | [0, 600, 450, 0.75], 9 | [0, 600, 1200, 2], 10 | [0, 600, -600, -1], 11 | ])( 12 | 'getProgressAmount(%i, %i, %i, %i)', 13 | (start, totalDist, currentScroll, expected) => { 14 | test(`returns ${expected}%`, () => { 15 | expect(getProgressAmount(start, totalDist, currentScroll)).toBe(expected); 16 | }); 17 | } 18 | ); 19 | -------------------------------------------------------------------------------- /src/helpers/getProgressAmount.ts: -------------------------------------------------------------------------------- 1 | import bezier from 'bezier-easing'; 2 | 3 | /** 4 | * Returns the percent (0 - 100) moved based on position in the viewport 5 | */ 6 | 7 | export function getProgressAmount( 8 | /* 9 | * The start value from cache 10 | */ 11 | start: number, 12 | /* 13 | * total dist the element has to move to be 100% complete (view width/height + element width/height) 14 | */ 15 | totalDist: number, 16 | /* 17 | * Current scroll value 18 | */ 19 | currentScroll: number, 20 | /* 21 | * an optional easing function to apply 22 | */ 23 | easing?: bezier.EasingFunction 24 | ): number { 25 | // adjust cached value 26 | const startAdjustedScroll = currentScroll - start; 27 | 28 | // Amount the element has moved based on current and total distance to move 29 | let amount = startAdjustedScroll / totalDist; 30 | 31 | // Apply bezier easing if provided 32 | if (easing) { 33 | amount = easing(amount); 34 | } 35 | 36 | return amount; 37 | } 38 | -------------------------------------------------------------------------------- /src/helpers/getShouldScaleTranslateEffects.test.ts: -------------------------------------------------------------------------------- 1 | import { ScrollAxis } from '..'; 2 | import { getShouldScaleTranslateEffects } from './getShouldScaleTranslateEffects'; 3 | 4 | describe('given getShouldScaleTranslateEffects()', () => { 5 | describe('when a root margin is provided', () => { 6 | test(`then it returns false`, () => { 7 | expect( 8 | getShouldScaleTranslateEffects( 9 | { rootMargin: { top: 10, bottom: 10, left: 10, right: 10 } }, 10 | {}, 11 | ScrollAxis.vertical 12 | ) 13 | ).toEqual(false); 14 | }); 15 | }); 16 | 17 | describe('when shouldDisableScalingTranslations is true', () => { 18 | test(`then it returns false`, () => { 19 | expect( 20 | getShouldScaleTranslateEffects( 21 | { shouldDisableScalingTranslations: true }, 22 | {}, 23 | ScrollAxis.vertical 24 | ) 25 | ).toEqual(false); 26 | }); 27 | }); 28 | describe('when axis is horizontal and', () => { 29 | describe('when translateX provided', () => { 30 | test(`then it returns true`, () => { 31 | expect( 32 | getShouldScaleTranslateEffects( 33 | {}, 34 | { translateX: { start: 10, end: 100, unit: 'px' } }, 35 | ScrollAxis.horizontal 36 | ) 37 | ).toEqual(true); 38 | }); 39 | }); 40 | describe('when translateY is provided', () => { 41 | test(`then it returns false`, () => { 42 | expect( 43 | getShouldScaleTranslateEffects( 44 | {}, 45 | { translateY: { start: 10, end: 100, unit: 'px' } }, 46 | ScrollAxis.horizontal 47 | ) 48 | ).toEqual(false); 49 | }); 50 | }); 51 | describe('when translateY and translateX are provided', () => { 52 | test(`then it returns true`, () => { 53 | expect( 54 | getShouldScaleTranslateEffects( 55 | {}, 56 | { 57 | translateY: { start: 10, end: 100, unit: 'px' }, 58 | translateX: { start: 10, end: 100, unit: 'px' }, 59 | }, 60 | ScrollAxis.horizontal 61 | ) 62 | ).toEqual(true); 63 | }); 64 | }); 65 | }); 66 | describe('when axis is vertical and', () => { 67 | describe('when translateX provided', () => { 68 | test(`then it returns false`, () => { 69 | expect( 70 | getShouldScaleTranslateEffects( 71 | {}, 72 | { translateX: { start: 10, end: 100, unit: 'px' } }, 73 | ScrollAxis.vertical 74 | ) 75 | ).toEqual(false); 76 | }); 77 | }); 78 | describe('when translateY is provided', () => { 79 | test(`then it returns true`, () => { 80 | expect( 81 | getShouldScaleTranslateEffects( 82 | {}, 83 | { translateY: { start: 10, end: 100, unit: 'px' } }, 84 | ScrollAxis.vertical 85 | ) 86 | ).toEqual(true); 87 | }); 88 | }); 89 | describe('when translateY and translateX are provided', () => { 90 | test(`then it returns true`, () => { 91 | expect( 92 | getShouldScaleTranslateEffects( 93 | {}, 94 | { 95 | translateY: { start: 10, end: 100, unit: 'px' }, 96 | translateX: { start: 10, end: 100, unit: 'px' }, 97 | }, 98 | ScrollAxis.vertical 99 | ) 100 | ).toEqual(true); 101 | }); 102 | }); 103 | }); 104 | describe('when a target element is provided', () => { 105 | test(`then it returns false`, () => { 106 | expect( 107 | getShouldScaleTranslateEffects( 108 | { targetElement: document.createElement('div') }, 109 | {}, 110 | ScrollAxis.vertical 111 | ) 112 | ).toEqual(false); 113 | }); 114 | }); 115 | 116 | describe('when a scale effect is provided', () => { 117 | test(`then it returns false`, () => { 118 | expect( 119 | getShouldScaleTranslateEffects( 120 | {}, 121 | { 122 | scale: { start: 10, end: 100, unit: '' }, 123 | scaleX: { start: 10, end: 100, unit: '' }, 124 | scaleY: { start: 10, end: 100, unit: '' }, 125 | scaleZ: { start: 10, end: 100, unit: '' }, 126 | }, 127 | ScrollAxis.vertical 128 | ) 129 | ).toEqual(false); 130 | }); 131 | }); 132 | describe('when a rotate effect is provided', () => { 133 | test(`then it returns false`, () => { 134 | expect( 135 | getShouldScaleTranslateEffects( 136 | {}, 137 | { 138 | rotate: { start: 10, end: 100, unit: '' }, 139 | rotateX: { start: 10, end: 100, unit: '' }, 140 | rotateY: { start: 10, end: 100, unit: '' }, 141 | rotateZ: { start: 10, end: 100, unit: '' }, 142 | }, 143 | ScrollAxis.vertical 144 | ) 145 | ).toEqual(false); 146 | }); 147 | }); 148 | describe('when a opacity effect is provided', () => { 149 | test(`then it returns false`, () => { 150 | expect( 151 | getShouldScaleTranslateEffects( 152 | {}, 153 | { opacity: { start: 0, end: 1, unit: '' } }, 154 | ScrollAxis.vertical 155 | ) 156 | ).toEqual(false); 157 | }); 158 | }); 159 | }); 160 | -------------------------------------------------------------------------------- /src/helpers/getShouldScaleTranslateEffects.ts: -------------------------------------------------------------------------------- 1 | import { ScrollAxis, ValidScrollAxis } from '../types'; 2 | import { ParallaxElementConfig, ParallaxStartEndEffects } from '../types'; 3 | 4 | export function getShouldScaleTranslateEffects( 5 | props: ParallaxElementConfig, 6 | effects: ParallaxStartEndEffects, 7 | scrollAxis: ValidScrollAxis 8 | ): boolean { 9 | if ( 10 | props.rootMargin || 11 | props.targetElement || 12 | props.shouldDisableScalingTranslations 13 | ) { 14 | return false; 15 | } 16 | 17 | if ( 18 | (!!effects.translateX && scrollAxis === ScrollAxis.horizontal) || 19 | (!!effects.translateY && scrollAxis === ScrollAxis.vertical) 20 | ) { 21 | return true; 22 | } 23 | 24 | return false; 25 | } 26 | -------------------------------------------------------------------------------- /src/helpers/getStartEndValueInPx.test.ts: -------------------------------------------------------------------------------- 1 | import { ParsedValueEffect } from '..'; 2 | import { getStartEndValueInPx } from './getStartEndValueInPx'; 3 | 4 | describe('getStartEndValueInPx', () => { 5 | test('passes through start and end for pixel values', () => { 6 | const translate: ParsedValueEffect = { start: 100, end: -100, unit: 'px' }; 7 | const size = 300; 8 | expect(getStartEndValueInPx(translate, size)).toEqual({ 9 | start: 100, 10 | end: -100, 11 | }); 12 | }); 13 | 14 | test('handles percent and calculates the pixel values based on the given element width/height', () => { 15 | const translate: ParsedValueEffect = { 16 | start: 100, 17 | end: -100, 18 | unit: '%', 19 | }; 20 | const size = 300; 21 | expect(getStartEndValueInPx(translate, size)).toEqual({ 22 | start: 300, 23 | end: -300, 24 | }); 25 | }); 26 | 27 | test('handles vh units and calculates the pixel values based on the window', () => { 28 | const translate: ParsedValueEffect = { 29 | start: 100, 30 | end: -100, 31 | unit: 'vh', 32 | }; 33 | expect(getStartEndValueInPx(translate, 100)).toEqual({ 34 | start: window.innerHeight, 35 | end: -window.innerHeight, 36 | }); 37 | }); 38 | 39 | test('handles vw units and calculates the pixel values based on the window', () => { 40 | const translate: ParsedValueEffect = { 41 | start: 50, 42 | end: -50, 43 | unit: 'vw', 44 | }; 45 | expect(getStartEndValueInPx(translate, 100)).toEqual({ 46 | start: window.innerWidth / 2, 47 | end: -window.innerWidth / 2, 48 | }); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/helpers/getStartEndValueInPx.ts: -------------------------------------------------------------------------------- 1 | import { ParsedValueEffect } from '..'; 2 | 3 | /** 4 | * Return the start and end pixel values for an elements translations 5 | */ 6 | export function getStartEndValueInPx( 7 | translate: ParsedValueEffect, 8 | elementSize: number 9 | ) { 10 | let { start, end, unit } = translate; 11 | 12 | if (unit === '%') { 13 | const scale = elementSize / 100; 14 | start = start * scale; 15 | end = end * scale; 16 | } 17 | 18 | if (unit === 'vw') { 19 | const startScale = start / 100; 20 | const endScale = end / 100; 21 | start = window.innerWidth * startScale; 22 | end = window.innerWidth * endScale; 23 | } 24 | 25 | if (unit === 'vh') { 26 | const startScale = start / 100; 27 | const endScale = end / 100; 28 | start = window.innerHeight * startScale; 29 | end = window.innerHeight * endScale; 30 | } 31 | 32 | return { 33 | start, 34 | end, 35 | }; 36 | } 37 | -------------------------------------------------------------------------------- /src/helpers/getTranslateScalar.ts: -------------------------------------------------------------------------------- 1 | export function getTranslateScalar( 2 | startTranslatePx: number, 3 | endTranslatePx: number, 4 | totalDist: number 5 | ) { 6 | const slow = endTranslatePx > startTranslatePx; 7 | 8 | // calculating necessary scale to increase translations 9 | const totalAbsOff = 10 | (Math.abs(startTranslatePx) + Math.abs(endTranslatePx)) * (slow ? -1 : 1); 11 | const totalDistTrue = totalDist + totalAbsOff; 12 | 13 | // Determine multiple to scale by, only values greater than 1 14 | const scale = Math.max(totalDist / totalDistTrue, 1); 15 | 16 | return scale; 17 | } 18 | -------------------------------------------------------------------------------- /src/helpers/isElementInView.test.ts: -------------------------------------------------------------------------------- 1 | import { isElementInView } from './isElementInView'; 2 | 3 | describe.each([ 4 | [0, 500, 1000, false], 5 | [2500, 4000, 0, false], 6 | [2500, 8000, 0, false], 7 | [2500, 4000, 4001, false], 8 | [2500, 4000, 2500, true], 9 | [2500, 4000, 2000, false], 10 | [2500, 4000, 1999, false], 11 | [2500, 4000, 6500, false], 12 | ])( 13 | '.isElementInView(%i, %i, %i)', 14 | (start: number, end: number, scroll: number, expected) => { 15 | test(`returns ${expected}%`, () => { 16 | expect(isElementInView(start, end, scroll)).toBe(expected); 17 | }); 18 | } 19 | ); 20 | -------------------------------------------------------------------------------- /src/helpers/isElementInView.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Takes two values (start, end) and returns whether the current scroll is within range 3 | * @param {number} start - start of scroll (x/y) 4 | * @param {number} end - end of scroll (x/y) 5 | * @param {number} scroll - current scroll (x/y) 6 | * @return {boolean} isInView 7 | */ 8 | 9 | export function isElementInView( 10 | start: number, 11 | end: number, 12 | scroll: number 13 | ): boolean { 14 | const isInView = scroll >= start && scroll <= end; 15 | 16 | return isInView; 17 | } 18 | -------------------------------------------------------------------------------- /src/helpers/parseElementTransitionEffects.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PARALLAX_EFFECTS, 3 | parseElementTransitionEffects, 4 | } from './parseElementTransitionEffects'; 5 | import { CSSEffect, ScaleOpacityEffect } from '../types'; 6 | import { ScrollAxis } from '..'; 7 | 8 | describe('parseElementTransitionEffects', () => { 9 | it('returns the offset properties to an element with defaults', () => { 10 | const props: { 11 | translateY: CSSEffect; 12 | translateX: CSSEffect; 13 | } = { 14 | translateY: [0, 0], 15 | translateX: [0, 0], 16 | }; 17 | expect(parseElementTransitionEffects(props, ScrollAxis.vertical)).toEqual({ 18 | translateY: { 19 | start: 0, 20 | end: 0, 21 | unit: '%', 22 | easing: undefined, 23 | }, 24 | translateX: { 25 | start: 0, 26 | end: 0, 27 | unit: '%', 28 | easing: undefined, 29 | }, 30 | }); 31 | }); 32 | 33 | it('adds the offset properties to an element with various units', () => { 34 | const props: { 35 | translateY: CSSEffect; 36 | translateX: CSSEffect; 37 | } = { 38 | translateY: ['100px', '-50px'], 39 | translateX: ['100%', '300%'], 40 | }; 41 | expect(parseElementTransitionEffects(props, ScrollAxis.vertical)).toEqual({ 42 | translateY: { 43 | start: 100, 44 | end: -50, 45 | unit: 'px', 46 | easing: undefined, 47 | }, 48 | translateX: { 49 | start: 100, 50 | end: 300, 51 | unit: '%', 52 | easing: undefined, 53 | }, 54 | }); 55 | }); 56 | 57 | it('parses and returns all parallax effects', () => { 58 | const effects = PARALLAX_EFFECTS.reduce((acc: any, effect, i) => { 59 | acc[effect] = [`${(i + 1) * 1}px`, `${(i + 1) * -1}px`]; 60 | return acc; 61 | }, {}); 62 | 63 | const expectedParsedEffects = PARALLAX_EFFECTS.reduce( 64 | (acc: any, effect, i) => { 65 | acc[effect] = { 66 | start: (i + 1) * 1, 67 | end: (i + 1) * -1, 68 | unit: 'px', 69 | easing: undefined, 70 | }; 71 | return acc; 72 | }, 73 | {} 74 | ); 75 | 76 | expect(parseElementTransitionEffects(effects, ScrollAxis.vertical)).toEqual( 77 | expectedParsedEffects 78 | ); 79 | }); 80 | 81 | describe('parses speed', () => { 82 | it('and creates proper translate y effect for vertical scrolling', () => { 83 | const effects = { speed: -10 }; 84 | 85 | const expectedParsedEffects = { 86 | translateY: { 87 | start: 10 * effects.speed, 88 | end: -10 * effects.speed, 89 | unit: 'px', 90 | }, 91 | }; 92 | 93 | expect( 94 | parseElementTransitionEffects(effects, ScrollAxis.vertical) 95 | ).toEqual(expectedParsedEffects); 96 | }); 97 | 98 | it('and creates proper translate x effect for horizontal scrolling', () => { 99 | const effects = { speed: -10 }; 100 | 101 | const expectedParsedEffects = { 102 | translateX: { 103 | start: 10 * effects.speed, 104 | end: -10 * effects.speed, 105 | unit: 'px', 106 | }, 107 | }; 108 | 109 | expect( 110 | parseElementTransitionEffects(effects, ScrollAxis.horizontal) 111 | ).toEqual(expectedParsedEffects); 112 | }); 113 | }); 114 | 115 | it('parses the scale properties for an element', () => { 116 | const scaleProps: { 117 | scale: ScaleOpacityEffect; 118 | } = { 119 | scale: [1, 2], 120 | }; 121 | expect( 122 | parseElementTransitionEffects(scaleProps, ScrollAxis.vertical) 123 | ).toEqual({ 124 | scale: { 125 | start: 1, 126 | end: 2, 127 | unit: '', 128 | easing: undefined, 129 | }, 130 | }); 131 | const scaleXProps: { 132 | scaleX: ScaleOpacityEffect; 133 | } = { 134 | scaleX: [1.3, 0], 135 | }; 136 | expect( 137 | parseElementTransitionEffects(scaleXProps, ScrollAxis.vertical) 138 | ).toEqual({ 139 | scaleX: { 140 | start: 1.3, 141 | end: 0, 142 | unit: '', 143 | easing: undefined, 144 | }, 145 | }); 146 | const scaleYProps: { 147 | scaleY: ScaleOpacityEffect; 148 | } = { 149 | scaleY: [0, 1], 150 | }; 151 | expect( 152 | parseElementTransitionEffects(scaleYProps, ScrollAxis.vertical) 153 | ).toEqual({ 154 | scaleY: { 155 | start: 0, 156 | end: 1, 157 | unit: '', 158 | easing: undefined, 159 | }, 160 | }); 161 | const scaleZProps: { 162 | scaleZ: ScaleOpacityEffect; 163 | } = { 164 | scaleZ: [0, 1], 165 | }; 166 | expect( 167 | parseElementTransitionEffects(scaleZProps, ScrollAxis.vertical) 168 | ).toEqual({ 169 | scaleZ: { 170 | start: 0, 171 | end: 1, 172 | unit: '', 173 | easing: undefined, 174 | }, 175 | }); 176 | }); 177 | 178 | it('ignores and omits effects if no values are provided', () => { 179 | expect(parseElementTransitionEffects({}, ScrollAxis.vertical)).toEqual({}); 180 | }); 181 | 182 | it("to throw if matching units aren't provided", () => { 183 | const props: { 184 | translateY: CSSEffect; 185 | translateX: CSSEffect; 186 | } = { translateY: ['100px', '-50%'], translateX: ['100px', '300%'] }; 187 | expect(() => 188 | parseElementTransitionEffects(props, ScrollAxis.vertical) 189 | ).toThrow(); 190 | }); 191 | }); 192 | -------------------------------------------------------------------------------- /src/helpers/parseElementTransitionEffects.ts: -------------------------------------------------------------------------------- 1 | import { CSSEffect, ScrollAxis, ValidScrollAxis } from '..'; 2 | import { 3 | ParsedValueEffect, 4 | ValidCSSEffects, 5 | ParallaxElementConfig, 6 | ParallaxStartEndEffects, 7 | AllValidUnits, 8 | } from '../types'; 9 | import { parseValueAndUnit } from '../utils/parseValueAndUnit'; 10 | import { createEasingFunction } from './createEasingFunction'; 11 | 12 | export const PARALLAX_EFFECTS = Object.values(ValidCSSEffects); 13 | 14 | export const MAP_EFFECT_TO_DEFAULT_UNIT: { 15 | [key in ValidCSSEffects]: AllValidUnits; 16 | } = { 17 | speed: 'px', 18 | translateX: '%', 19 | translateY: '%', 20 | rotate: 'deg', 21 | rotateX: 'deg', 22 | rotateY: 'deg', 23 | rotateZ: 'deg', 24 | scale: '', 25 | scaleX: '', 26 | scaleY: '', 27 | scaleZ: '', 28 | opacity: '', 29 | }; 30 | /** 31 | * Takes a parallax element effects and parses the properties to get the start and end values and units. 32 | */ 33 | export function parseElementTransitionEffects( 34 | props: ParallaxElementConfig, 35 | scrollAxis: ValidScrollAxis 36 | ): ParallaxStartEndEffects { 37 | const parsedEffects: { [key: string]: ParsedValueEffect } = {}; 38 | 39 | PARALLAX_EFFECTS.forEach((key: keyof typeof ValidCSSEffects) => { 40 | const defaultValue: AllValidUnits = MAP_EFFECT_TO_DEFAULT_UNIT[key]; 41 | 42 | // If the provided type is a number, this must be the speed prop 43 | // in which case we need to construct the proper translate config 44 | if (typeof props?.[key] === 'number') { 45 | const value = props?.[key] as number; 46 | const startSpeed = `${(value || 0) * 10}px`; 47 | const endSpeed = `${(value || 0) * -10}px`; 48 | 49 | const startParsed = parseValueAndUnit(startSpeed); 50 | const endParsed = parseValueAndUnit(endSpeed); 51 | 52 | const speedConfig = { 53 | start: startParsed.value, 54 | end: endParsed.value, 55 | unit: startParsed.unit, 56 | }; 57 | 58 | // Manually set translate y value 59 | if (scrollAxis === ScrollAxis.vertical) { 60 | parsedEffects.translateY = speedConfig; 61 | } 62 | 63 | // Manually set translate y value 64 | if (scrollAxis === ScrollAxis.horizontal) { 65 | parsedEffects.translateX = speedConfig; 66 | } 67 | } 68 | 69 | // The rest are standard effect being parsed 70 | if (Array.isArray(props?.[key])) { 71 | const value = props?.[key] as CSSEffect; 72 | 73 | if (typeof value[0] !== 'undefined' && typeof value[1] !== 'undefined') { 74 | const startParsed = parseValueAndUnit(value?.[0], defaultValue); 75 | const endParsed = parseValueAndUnit(value?.[1], defaultValue); 76 | 77 | const easing = createEasingFunction(value?.[2]); 78 | 79 | parsedEffects[key] = { 80 | start: startParsed.value, 81 | end: endParsed.value, 82 | unit: startParsed.unit, 83 | easing, 84 | }; 85 | 86 | if (startParsed.unit !== endParsed.unit) { 87 | throw new Error( 88 | 'Must provide matching units for the min and max offset values of each axis.' 89 | ); 90 | } 91 | } 92 | } 93 | }); 94 | 95 | return parsedEffects; 96 | } 97 | -------------------------------------------------------------------------------- /src/helpers/scaleEffectByProgress.test.ts: -------------------------------------------------------------------------------- 1 | import { scaleEffectByProgress } from './scaleEffectByProgress'; 2 | import { scaleBetween } from '../utils/scaleBetween'; 3 | import { ParsedValueEffect } from '../types'; 4 | 5 | const translateX: ParsedValueEffect = { 6 | start: 100, 7 | end: 40, 8 | unit: 'px', 9 | easing: undefined, 10 | }; 11 | const translateY: ParsedValueEffect = { 12 | start: 80, 13 | end: 50, 14 | unit: '%', 15 | easing: undefined, 16 | }; 17 | const scale: ParsedValueEffect = { 18 | start: 0, 19 | end: 1, 20 | unit: '', 21 | easing: undefined, 22 | }; 23 | 24 | const progress = 0.44; 25 | 26 | describe('scaleEffectByProgress', () => { 27 | test('Gets offsets based on percent in view', () => { 28 | expect(scaleEffectByProgress(translateX, progress)).toEqual({ 29 | value: scaleBetween(progress, translateX.start, translateX.end, 0, 1), 30 | unit: 'px', 31 | }); 32 | expect(scaleEffectByProgress(translateY, progress)).toEqual({ 33 | value: scaleBetween(progress, translateY.start, translateY.end, 0, 1), 34 | unit: '%', 35 | }); 36 | expect(scaleEffectByProgress(scale, progress)).toEqual({ 37 | value: scaleBetween(progress, scale.start, scale.end, 0, 1), 38 | unit: '', 39 | }); 40 | }); 41 | test('to call easing function when available', () => { 42 | const easeExpect = 0.999; 43 | const translateYEased: ParsedValueEffect = { 44 | start: 0, 45 | end: 1, 46 | unit: '', 47 | easing: jest.fn(() => easeExpect), 48 | }; 49 | expect(scaleEffectByProgress(translateYEased, progress)).toEqual({ 50 | value: easeExpect, 51 | unit: '', 52 | }); 53 | expect(translateYEased.easing).toBeCalledWith(0.44); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /src/helpers/scaleEffectByProgress.ts: -------------------------------------------------------------------------------- 1 | import { ParsedValueEffect } from '..'; 2 | import { AllValidUnits } from '../types'; 3 | import { scaleBetween } from '../utils/scaleBetween'; 4 | 5 | /** 6 | * Scales a start and end value of an effect based on percent moved and easing function 7 | */ 8 | export function scaleEffectByProgress( 9 | effect: ParsedValueEffect, 10 | progress: number 11 | ): { 12 | value: number; 13 | unit: AllValidUnits; 14 | } { 15 | const value = scaleBetween( 16 | typeof effect.easing === 'function' ? effect.easing(progress) : progress, 17 | effect?.start || 0, 18 | effect?.end || 0, 19 | 0, 20 | 1 21 | ); 22 | 23 | return { 24 | value, 25 | unit: effect?.unit, 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /src/helpers/scaleTranslateEffectsForSlowerScroll.ts: -------------------------------------------------------------------------------- 1 | import { ParsedValueEffect, ParallaxStartEndEffects } from '../types'; 2 | import { Limits } from '../classes/Limits'; 3 | 4 | export function scaleTranslateEffectsForSlowerScroll( 5 | effects: ParallaxStartEndEffects, 6 | limits: Limits 7 | ): ParallaxStartEndEffects { 8 | const effectsCopy = { 9 | ...effects, 10 | }; 11 | 12 | if (effectsCopy.translateX) { 13 | effectsCopy.translateX = { 14 | ...effects.translateX, 15 | start: effectsCopy.translateX.start * limits.startMultiplierX, 16 | end: effectsCopy.translateX.end * limits.endMultiplierX, 17 | } as ParsedValueEffect; 18 | } 19 | if (effectsCopy.translateY) { 20 | effectsCopy.translateY = { 21 | ...effects.translateY, 22 | start: effectsCopy.translateY.start * limits.startMultiplierY, 23 | end: effectsCopy.translateY.end * limits.endMultiplierY, 24 | } as ParsedValueEffect; 25 | } 26 | 27 | return effectsCopy; 28 | } 29 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Limits } from './classes/Limits'; 2 | import { Element } from './classes/Element'; 3 | import { ParallaxController } from './classes/ParallaxController'; 4 | import { Rect } from './classes/Rect'; 5 | import { Scroll } from './classes/Scroll'; 6 | import { View } from './classes/View'; 7 | 8 | import { setElementStyles, resetStyles } from './helpers/elementStyles'; 9 | import { parseElementTransitionEffects } from './helpers/parseElementTransitionEffects'; 10 | import { scaleEffectByProgress } from './helpers/scaleEffectByProgress'; 11 | import { isElementInView } from './helpers/isElementInView'; 12 | import { getProgressAmount } from './helpers/getProgressAmount'; 13 | 14 | import { createId } from './utils/createId'; 15 | import { parseValueAndUnit } from './utils/parseValueAndUnit'; 16 | import { scaleBetween } from './utils/scaleBetween'; 17 | import { testForPassiveScroll } from './utils/testForPassiveScroll'; 18 | 19 | export * from './types'; 20 | 21 | export { 22 | Limits, 23 | Element, 24 | ParallaxController, 25 | Rect, 26 | Scroll, 27 | View, 28 | setElementStyles, 29 | resetStyles, 30 | parseElementTransitionEffects, 31 | scaleEffectByProgress, 32 | isElementInView, 33 | getProgressAmount, 34 | createId, 35 | parseValueAndUnit, 36 | scaleBetween, 37 | testForPassiveScroll, 38 | }; 39 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | global.ResizeObserver = jest.fn().mockImplementation(() => ({ 2 | observe: jest.fn(), 3 | unobserve: jest.fn(), 4 | disconnect: jest.fn(), 5 | })); 6 | -------------------------------------------------------------------------------- /src/testUtils/createElementMock.ts: -------------------------------------------------------------------------------- 1 | export function createElementMock(properties = {}, methods = {}) { 2 | const element = document.createElement('div'); 3 | Object.keys(properties).map(key => { 4 | Object.defineProperty(element, key, { 5 | // @ts-ignore 6 | value: properties[key], 7 | writable: false, 8 | }); 9 | }); 10 | 11 | Object.keys(methods).map(key => { 12 | // @ts-ignore 13 | element[key] = methods[key]; 14 | }); 15 | 16 | return element; 17 | } 18 | -------------------------------------------------------------------------------- /src/testUtils/createNodeMock.ts: -------------------------------------------------------------------------------- 1 | // Workaround for refs 2 | // See https://github.com/facebook/react/issues/7740 3 | export default function createNodeMock() { 4 | const div = document.createElement('div'); 5 | 6 | return { 7 | getBoundingClientRect: () => div.getBoundingClientRect(), 8 | style: { transform: '', opacity: '' }, 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { EasingFunction } from 'bezier-easing'; 2 | import { Element } from './classes/Element'; 3 | 4 | export type ParallaxStartEndEffects = { 5 | translateX?: ParsedValueEffect; 6 | translateY?: ParsedValueEffect; 7 | rotate?: ParsedValueEffect; 8 | rotateX?: ParsedValueEffect; 9 | rotateY?: ParsedValueEffect; 10 | rotateZ?: ParsedValueEffect; 11 | scale?: ParsedValueEffect; 12 | scaleX?: ParsedValueEffect; 13 | scaleY?: ParsedValueEffect; 14 | scaleZ?: ParsedValueEffect; 15 | opacity?: ParsedValueEffect; 16 | }; 17 | 18 | export enum ValidCSSEffects { 19 | 'speed' = 'speed', 20 | 'translateX' = 'translateX', 21 | 'translateY' = 'translateY', 22 | 'rotate' = 'rotate', 23 | 'rotateX' = 'rotateX', 24 | 'rotateY' = 'rotateY', 25 | 'rotateZ' = 'rotateZ', 26 | 'scale' = 'scale', 27 | 'scaleX' = 'scaleX', 28 | 'scaleY' = 'scaleY', 29 | 'scaleZ' = 'scaleZ', 30 | 'opacity' = 'opacity', 31 | } 32 | 33 | export enum Units { 34 | 'px' = 'px', 35 | '%' = '%', 36 | 'vh' = 'vh', 37 | 'vw' = 'vw', 38 | } 39 | export type ValidUnits = keyof typeof Units; 40 | 41 | export enum RotationUnits { 42 | 'deg' = 'deg', 43 | 'turn' = 'turn', 44 | 'rad' = 'rad', 45 | } 46 | 47 | export enum ScaleUnits { 48 | '' = '', 49 | } 50 | 51 | export type ValidScaleUnits = keyof typeof ScaleUnits; 52 | 53 | export type ValidRotationUnits = keyof typeof RotationUnits; 54 | 55 | export type AllValidUnits = ValidUnits | ValidRotationUnits | ValidScaleUnits; 56 | 57 | export enum ScrollAxis { 58 | 'vertical' = 'vertical', 59 | 'horizontal' = 'horizontal', 60 | } 61 | 62 | export type ValidScrollAxis = keyof typeof ScrollAxis; 63 | 64 | export type ParsedValueShape = { 65 | value: number; 66 | unit: AllValidUnits; 67 | }; 68 | 69 | export type ParsedValueEffect = { 70 | start: number; 71 | end: number; 72 | unit: AllValidUnits; 73 | easing?: EasingFunction; 74 | }; 75 | 76 | export type ViewElement = HTMLElement | Window; 77 | export type ParallaxControllerOptions = { 78 | scrollAxis?: ValidScrollAxis; 79 | scrollContainer?: HTMLElement; 80 | disabled?: boolean; 81 | }; 82 | 83 | export type EffectNumber = [number, number, EasingParam?]; 84 | export type EffectString = [string, string, EasingParam?]; 85 | export type EasingParam = ValidEasingPresets | EasingParams; 86 | export type CSSEffect = EffectNumber | EffectString; 87 | export type ScaleOpacityEffect = EffectNumber; 88 | 89 | export type ParallaxElementConfig = { 90 | speed?: number; 91 | disabled?: boolean; 92 | translateX?: CSSEffect; 93 | translateY?: CSSEffect; 94 | rotate?: CSSEffect; 95 | rotateX?: CSSEffect; 96 | rotateY?: CSSEffect; 97 | rotateZ?: CSSEffect; 98 | scale?: ScaleOpacityEffect; 99 | scaleX?: ScaleOpacityEffect; 100 | scaleY?: ScaleOpacityEffect; 101 | scaleZ?: ScaleOpacityEffect; 102 | opacity?: ScaleOpacityEffect; 103 | easing?: EasingParams | ValidEasingPresets; 104 | rootMargin?: RootMarginShape; 105 | /* Always start and end animations at the given effect values - if the element is positioned inside the view when scroll is at zero or ends in view at final scroll position, the initial and final positions are used to determine progress instead of the scroll view size */ 106 | shouldAlwaysCompleteAnimation?: boolean; 107 | /* Disable scaling translations - translate effects that cause the element to appear in the view longer must be scaled up so that animation doesn't end early */ 108 | shouldDisableScalingTranslations?: boolean; 109 | 110 | startScroll?: number; 111 | endScroll?: number; 112 | targetElement?: HTMLElement; 113 | 114 | onEnter?: (element: Element) => any; 115 | onExit?: (element: Element) => any; 116 | onChange?: (element: Element) => any; 117 | onProgressChange?: (progress: number) => any; 118 | }; 119 | 120 | export type CreateElementOptions = { 121 | el: HTMLElement; 122 | props: ParallaxElementConfig; 123 | }; 124 | 125 | export type EasingParams = [number, number, number, number]; 126 | 127 | export enum EasingPreset { 128 | ease = 'ease', 129 | easeIn = 'easeIn', 130 | easeOut = 'easeOut', 131 | easeInOut = 'easeInOut', 132 | easeInQuad = 'easeInQuad', 133 | easeInCubic = 'easeInCubic', 134 | easeInQuart = 'easeInQuart', 135 | easeInQuint = 'easeInQuint', 136 | easeInSine = 'easeInSine', 137 | easeInExpo = 'easeInExpo', 138 | easeInCirc = 'easeInCirc', 139 | easeOutQuad = 'easeOutQuad', 140 | easeOutCubic = 'easeOutCubic', 141 | easeOutQuart = 'easeOutQuart', 142 | easeOutQuint = 'easeOutQuint', 143 | easeOutSine = 'easeOutSine', 144 | easeOutExpo = 'easeOutExpo', 145 | easeOutCirc = 'easeOutCirc', 146 | easeInOutQuad = 'easeInOutQuad', 147 | easeInOutCubic = 'easeInOutCubic', 148 | easeInOutQuart = 'easeInOutQuart', 149 | easeInOutQuint = 'easeInOutQuint', 150 | easeInOutSine = 'easeInOutSine', 151 | easeInOutExpo = 'easeInOutExpo', 152 | easeInOutCirc = 'easeInOutCirc', 153 | easeInBack = 'easeInBack', 154 | easeOutBack = 'easeOutBack', 155 | easeInOutBack = 'easeInOutBack', 156 | } 157 | 158 | export type ValidEasingPresets = keyof typeof EasingPreset; 159 | 160 | export type RootMarginShape = { 161 | top: number; 162 | bottom: number; 163 | left: number; 164 | right: number; 165 | }; 166 | -------------------------------------------------------------------------------- /src/utils/createId.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Creates a unique id to distinguish parallax elements. 3 | */ 4 | 5 | let id = 0; 6 | 7 | export function createId(): number { 8 | ++id; 9 | return id; 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/parseValueAndUnit.test.ts: -------------------------------------------------------------------------------- 1 | import { parseValueAndUnit } from './parseValueAndUnit'; 2 | 3 | describe('Parse a string to get the value and unit in either pixels or percent', () => { 4 | test('handle valid units', () => { 5 | expect(parseValueAndUnit()).toEqual({ unit: '%', value: 0 }); 6 | expect(parseValueAndUnit('5px')).toEqual({ unit: 'px', value: 5 }); 7 | expect(parseValueAndUnit('52%')).toEqual({ unit: '%', value: 52 }); 8 | expect(parseValueAndUnit(13.333)).toEqual({ unit: '%', value: 13.333 }); 9 | expect(parseValueAndUnit('75.8%')).toEqual({ unit: '%', value: 75.8 }); 10 | expect(parseValueAndUnit('10vw')).toEqual({ unit: 'vw', value: 10 }); 11 | expect(parseValueAndUnit('1.04vh')).toEqual({ unit: 'vh', value: 1.04 }); 12 | expect(parseValueAndUnit('23.1px')).toEqual({ unit: 'px', value: 23.1 }); 13 | expect(parseValueAndUnit('1.5turn')).toEqual({ unit: 'turn', value: 1.5 }); 14 | expect(parseValueAndUnit('143.4deg')).toEqual({ 15 | unit: 'deg', 16 | value: 143.4, 17 | }); 18 | expect(parseValueAndUnit('2.345rad')).toEqual({ 19 | unit: 'rad', 20 | value: 2.345, 21 | }); 22 | expect(parseValueAndUnit(10, '')).toEqual({ unit: '', value: 10 }); 23 | expect(parseValueAndUnit(0.47783, '')).toEqual({ 24 | unit: '', 25 | value: 0.47783, 26 | }); 27 | }); 28 | 29 | test('throw errors on invalid value or units', () => { 30 | // @ts-expect-error 31 | expect(() => parseValueAndUnit(false)).toThrow(); 32 | // @ts-expect-error 33 | expect(() => parseValueAndUnit(() => {})).toThrow(); 34 | // @ts-expect-error 35 | expect(() => parseValueAndUnit({ foo: 'bar' })).toThrow(); 36 | expect(() => parseValueAndUnit('100%%')).toThrow(); 37 | expect(() => parseValueAndUnit('100px%')).toThrow(); 38 | }); 39 | 40 | test('throw on unsupported units', () => { 41 | expect(() => parseValueAndUnit('1rem')).toThrow(); 42 | expect(() => parseValueAndUnit('1em')).toThrow(); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/utils/parseValueAndUnit.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ScaleUnits, 3 | ParsedValueShape, 4 | RotationUnits, 5 | Units, 6 | AllValidUnits, 7 | } from '../types'; 8 | 9 | export const VALID_UNITS = [ 10 | ScaleUnits[''], 11 | Units.px, 12 | Units['%'], 13 | Units['vh'], 14 | Units['vw'], 15 | RotationUnits.deg, 16 | RotationUnits.turn, 17 | RotationUnits.rad, 18 | ]; 19 | 20 | /** 21 | * Determines the unit of a string and parses the value 22 | */ 23 | 24 | export function parseValueAndUnit( 25 | str?: string | number, 26 | defaultUnit: AllValidUnits = Units['%'] 27 | ): ParsedValueShape { 28 | let out: ParsedValueShape = { value: 0, unit: defaultUnit }; 29 | 30 | if (typeof str === 'undefined') return out; 31 | 32 | const isValid = typeof str === 'number' || typeof str === 'string'; 33 | 34 | if (!isValid) { 35 | throw new Error( 36 | 'Invalid value provided. Must provide a value as a string or number' 37 | ); 38 | } 39 | 40 | str = String(str); 41 | out.value = parseFloat(str); 42 | 43 | // @ts-ignore 44 | out.unit = str.match(/[\d.\-+]*\s*(.*)/)[1] || defaultUnit; 45 | 46 | // @ts-expect-error 47 | const isValidUnit: boolean = VALID_UNITS.includes(out.unit); 48 | 49 | if (!isValidUnit) { 50 | throw new Error('Invalid unit provided.'); 51 | } 52 | 53 | return out; 54 | } 55 | -------------------------------------------------------------------------------- /src/utils/scaleBetween.test.ts: -------------------------------------------------------------------------------- 1 | import { scaleBetween } from './scaleBetween'; 2 | 3 | const oldMin = 0; 4 | const oldMax = 1; 5 | 6 | const newMin = 0; 7 | const newMax = 100; 8 | 9 | test('Scales a value from a given range to a new range', () => { 10 | expect(scaleBetween(0.4, newMin, newMax, oldMin, oldMax)).toBe(40); 11 | expect(scaleBetween(0.1, newMin, newMax, oldMin, oldMax)).toBe(10); 12 | expect(scaleBetween(0.3, newMin, newMax, oldMin, oldMax)).toBe(30); 13 | expect(scaleBetween(0.333, newMin, newMax, oldMin, oldMax)).toBeCloseTo(33.3); 14 | expect(scaleBetween(2, newMin, newMax, oldMin, oldMax)).toBe(200); 15 | expect(scaleBetween(-2, newMin, newMax, oldMin, oldMax)).toBe(-200); 16 | }); 17 | -------------------------------------------------------------------------------- /src/utils/scaleBetween.ts: -------------------------------------------------------------------------------- 1 | // Scale between AKA normalize 2 | export function scaleBetween( 3 | value: number, 4 | newMin: number, 5 | newMax: number, 6 | oldMin: number, 7 | oldMax: number 8 | ) { 9 | return ((newMax - newMin) * (value - oldMin)) / (oldMax - oldMin) + newMin; 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/testForPassiveScroll.test.ts: -------------------------------------------------------------------------------- 1 | import { testForPassiveScroll } from './testForPassiveScroll'; 2 | 3 | const addEventListener = window.addEventListener; 4 | const removeEventListener = window.removeEventListener; 5 | 6 | describe('Expect the testForPassiveScroll function', () => { 7 | afterEach(() => { 8 | window.addEventListener = addEventListener; 9 | window.removeEventListener = removeEventListener; 10 | }); 11 | 12 | it('to return a boolean', () => { 13 | const bool = testForPassiveScroll(); 14 | expect(bool).toBe(true); 15 | }); 16 | 17 | it('to add and remove a test listener', () => { 18 | window.addEventListener = jest.fn(); 19 | window.removeEventListener = jest.fn(); 20 | testForPassiveScroll(); 21 | // @ts-ignore 22 | expect(window.addEventListener.mock.calls[0]).toEqual( 23 | expect.arrayContaining(['test', null, expect.any(Object)]) 24 | ); 25 | // @ts-ignore 26 | expect(window.removeEventListener.mock.calls[0]).toEqual( 27 | expect.arrayContaining(['test', null, expect.any(Object)]) 28 | ); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/utils/testForPassiveScroll.ts: -------------------------------------------------------------------------------- 1 | export function testForPassiveScroll() { 2 | let supportsPassiveOption = false; 3 | try { 4 | const opts = Object.defineProperty({}, 'passive', { 5 | get() { 6 | supportsPassiveOption = true; 7 | return true; 8 | }, 9 | }); 10 | // @ts-expect-error 11 | window.addEventListener('test', null, opts); 12 | // @ts-expect-error 13 | window.removeEventListener('test', null, opts); 14 | } catch (e) {} 15 | return supportsPassiveOption; 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs 3 | "include": ["src", "types"], 4 | "compilerOptions": { 5 | "module": "esnext", 6 | "lib": ["dom", "esnext"], 7 | "importHelpers": true, 8 | // output .d.ts declaration files for consumers 9 | "declaration": true, 10 | // output .js.map sourcemap files for consumers 11 | "sourceMap": true, 12 | // match output dir to input dir. e.g. dist/index instead of dist/src/index 13 | "rootDir": "./src", 14 | // stricter type-checking for stronger correctness. Recommended by TS 15 | "strict": true, 16 | // linter checks for common issues 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": true, 19 | // noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | // use Node's module resolution algorithm, instead of the legacy TS one 23 | "moduleResolution": "node", 24 | // transpile JSX to React.createElement 25 | "jsx": "react", 26 | // interop between ESM and CJS modules. Recommended by TS 27 | "esModuleInterop": true, 28 | // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS 29 | "skipLibCheck": true, 30 | // error out if import and file system have a casing mismatch. Recommended by TS 31 | "forceConsistentCasingInFileNames": true, 32 | // `tsdx build` ignores this option, but it is commonly used when type-checking separately with `tsc` 33 | "noEmit": true, 34 | } 35 | } 36 | --------------------------------------------------------------------------------