├── .github ├── FUNDING.yml └── ISSUE_TEMPLATE │ ├── devtools_bug_report.md │ ├── devtools_feature_request.md │ ├── one_bug_report.md │ └── one_feature_request.md ├── .gitignore ├── .gitpod.yml ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README.md ├── lerna.json ├── package.json ├── packages ├── animation │ ├── .npmignore │ ├── README.md │ ├── jest.config.js │ ├── package.json │ ├── rollup.config.js │ ├── src │ │ ├── Animation.ts │ │ ├── __tests__ │ │ │ └── index.test.ts │ │ ├── index.ts │ │ └── utils │ │ │ ├── __tests__ │ │ │ └── easing.test.ts │ │ │ └── easing.ts │ └── tsconfig.json ├── config │ ├── eslint-preset.js │ ├── jest.config.js │ ├── jest.setup.ts │ ├── package.json │ ├── rollup.config.js │ ├── ts-base.json │ ├── ts-react.json │ └── waapi-polyfill.js ├── dom │ ├── .npmignore │ ├── README.md │ ├── jest.config.js │ ├── package.json │ ├── rollup.config.js │ ├── src │ │ ├── animate │ │ │ ├── README.md │ │ │ ├── __tests__ │ │ │ │ └── animate.test.ts │ │ │ ├── animate-style.ts │ │ │ ├── create-animate.ts │ │ │ ├── data.ts │ │ │ ├── index.ts │ │ │ ├── style.ts │ │ │ ├── types.ts │ │ │ └── utils │ │ │ │ ├── __tests__ │ │ │ │ ├── controls.test.ts │ │ │ │ ├── css-var.test.ts │ │ │ │ ├── easing.test.ts │ │ │ │ ├── get-unit.test.ts │ │ │ │ ├── keyframes.test.ts │ │ │ │ ├── options.test.ts │ │ │ │ ├── style-string.test.ts │ │ │ │ └── transforms.test.ts │ │ │ │ ├── controls.ts │ │ │ │ ├── css-var.ts │ │ │ │ ├── easing.ts │ │ │ │ ├── feature-detection.ts │ │ │ │ ├── get-style-name.ts │ │ │ │ ├── get-unit.ts │ │ │ │ ├── keyframes.ts │ │ │ │ ├── options.ts │ │ │ │ ├── stop-animation.ts │ │ │ │ ├── style-object.ts │ │ │ │ ├── style-string.ts │ │ │ │ └── transforms.ts │ │ ├── easing │ │ │ ├── __tests__ │ │ │ │ └── generator-easing.test.ts │ │ │ ├── create-generator-easing.ts │ │ │ ├── glide │ │ │ │ └── index.ts │ │ │ └── spring │ │ │ │ ├── __tests__ │ │ │ │ └── index.test.ts │ │ │ │ └── index.ts │ │ ├── gestures │ │ │ ├── __tests__ │ │ │ │ ├── in-view.test.ts │ │ │ │ └── mock-intersection-observer.ts │ │ │ ├── in-view.ts │ │ │ ├── resize │ │ │ │ ├── __tests__ │ │ │ │ │ └── mock-resize-observer.ts │ │ │ │ ├── handle-element.ts │ │ │ │ ├── handle-window.ts │ │ │ │ ├── index.ts │ │ │ │ └── types.ts │ │ │ └── scroll │ │ │ │ ├── __tests__ │ │ │ │ └── index.test.ts │ │ │ │ ├── index.ts │ │ │ │ ├── info.ts │ │ │ │ ├── offsets │ │ │ │ ├── __tests__ │ │ │ │ │ ├── edge.test.ts │ │ │ │ │ └── offset.test.ts │ │ │ │ ├── edge.ts │ │ │ │ ├── index.ts │ │ │ │ ├── inset.ts │ │ │ │ ├── offset.ts │ │ │ │ └── presets.ts │ │ │ │ ├── on-scroll-handler.ts │ │ │ │ └── types.ts │ │ ├── index.ts │ │ ├── state │ │ │ ├── __tests__ │ │ │ │ ├── hover.test.ts │ │ │ │ ├── in-view.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── press.test.ts │ │ │ │ └── utils.ts │ │ │ ├── gestures │ │ │ │ ├── hover.ts │ │ │ │ ├── in-view.ts │ │ │ │ ├── press.ts │ │ │ │ └── types.ts │ │ │ ├── index.ts │ │ │ ├── types.ts │ │ │ └── utils │ │ │ │ ├── events.ts │ │ │ │ ├── has-changed.ts │ │ │ │ ├── is-variant.ts │ │ │ │ ├── resolve-variant.ts │ │ │ │ └── schedule.ts │ │ ├── timeline │ │ │ ├── README.md │ │ │ ├── __tests__ │ │ │ │ └── index.test.ts │ │ │ ├── index.ts │ │ │ ├── types.ts │ │ │ └── utils │ │ │ │ ├── __tests__ │ │ │ │ ├── calc-time.test.ts │ │ │ │ ├── edit.test.ts │ │ │ │ └── sort.test.ts │ │ │ │ ├── calc-time.ts │ │ │ │ ├── edit.ts │ │ │ │ └── sort.ts │ │ ├── types.ts │ │ └── utils │ │ │ ├── __tests__ │ │ │ └── stagger.test.ts │ │ │ ├── resolve-elements.ts │ │ │ └── stagger.ts │ ├── tsconfig.json │ └── webpack.config.js ├── easing │ ├── .npmignore │ ├── README.md │ ├── jest.config.d.ts │ ├── jest.config.d.ts.map │ ├── jest.config.js │ ├── package.json │ ├── rollup.config.js │ ├── src │ │ ├── __tests__ │ │ │ ├── cubic-bezer.test.ts │ │ │ └── steps.test.ts │ │ ├── cubic-bezier.ts │ │ ├── index.ts │ │ ├── steps.ts │ │ └── types.ts │ └── tsconfig.json ├── generators │ ├── .npmignore │ ├── README.md │ ├── jest.config.js │ ├── package.json │ ├── rollup.config.js │ ├── src │ │ ├── glide │ │ │ ├── __tests__ │ │ │ │ └── index.test.ts │ │ │ ├── index.ts │ │ │ └── types.ts │ │ ├── index.ts │ │ ├── spring │ │ │ ├── defaults.ts │ │ │ ├── index.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ └── utils │ │ │ ├── __tests__ │ │ │ └── pregenerate-keyframes.test.ts │ │ │ ├── has-reached-target.ts │ │ │ ├── pregenerate-keyframes.ts │ │ │ └── velocity.ts │ └── tsconfig.json ├── motion │ ├── .npmignore │ ├── CHANGELOG.md │ ├── README.md │ ├── jest.config.js │ ├── package.json │ ├── rollup.config.js │ ├── src │ │ ├── __tests__ │ │ │ └── animate.test.ts │ │ ├── animate.ts │ │ └── index.ts │ └── tsconfig.json ├── types │ ├── .npmignore │ ├── README.md │ ├── package.json │ ├── rollup.config.js │ ├── src │ │ ├── MotionValue.ts │ │ └── index.ts │ └── tsconfig.json └── utils │ ├── .npmignore │ ├── README.md │ ├── jest.config.js │ ├── package.json │ ├── rollup.config.js │ ├── src │ ├── __tests__ │ │ ├── array.test.ts │ │ ├── clamp.test.ts │ │ ├── easing.test.ts │ │ ├── interpolate.test.ts │ │ ├── is.test.ts │ │ ├── mix.test.ts │ │ ├── offset.test.ts │ │ ├── progress.test.ts │ │ ├── time.test.ts │ │ ├── velocity.test.ts │ │ └── wrap.test.ts │ ├── array.ts │ ├── clamp.ts │ ├── defaults.ts │ ├── easing.ts │ ├── index.ts │ ├── interpolate.ts │ ├── is-cubic-bezier.ts │ ├── is-easing-generator.ts │ ├── is-easing-list.ts │ ├── is-function.ts │ ├── is-number.ts │ ├── is-string.ts │ ├── mix.ts │ ├── noop.ts │ ├── offset.ts │ ├── progress.ts │ ├── time.ts │ ├── velocity.ts │ └── wrap.ts │ └── tsconfig.json ├── tsconfig.json ├── turbo.json └── yarn.lock /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | custom: https://motion.dev/sponsor 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/devtools_bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Motion DevTools: Bug report" 3 | about: Let us know about broken functionality in Motion DevTools 4 | title: "[Bug] [DevTools] " 5 | labels: Bug 6 | assignees: "" 7 | --- 8 | 9 | **1. Describe the bug** 10 | 11 | Give a clear and concise description of what the bug is. 12 | 13 | **2. Steps to reproduce** 14 | 15 | Steps to reproduce the behavior: 16 | 17 | 1. Go to '...' 18 | 2. Click on '....' 19 | 3. Scroll down to '....' 20 | 4. See error 21 | 22 | **3. Expected behavior** 23 | 24 | A clear and concise description of what you expected to happen. 25 | 26 | **4. Video or screenshots** 27 | 28 | If applicable, add a video or screenshots to help explain the bug. 29 | 30 | **5. Browser details** 31 | 32 | If applicable, let us know which OS, browser and browser version etc you're using. 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/devtools_feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Motion DevTools: Feature request" 3 | about: Propose a feature for Motion DevTools 4 | title: "[Feature] [DevTools]" 5 | labels: Feature 6 | assignees: "" 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/one_bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Motion One: Bug report" 3 | about: Let us know about broken functionality in a Motion One library 4 | title: "[Bug] " 5 | labels: Bug 6 | assignees: "" 7 | --- 8 | 9 | **1. Describe the bug** 10 | 11 | Give a clear and concise description of what the bug is. 12 | 13 | **2. IMPORTANT: Provide a CodeSandbox reproduction of the bug** 14 | 15 | A CodeSandbox minimal reproduction will allow us to quickly follow the reproduction steps. **Without one, this bug report won't be accepted.** 16 | 17 | **3. Steps to reproduce** 18 | 19 | Steps to reproduce the behavior: 20 | 21 | 1. Go to '...' 22 | 2. Click on '....' 23 | 3. Scroll down to '....' 24 | 4. See error 25 | 26 | **4. Expected behavior** 27 | 28 | A clear and concise description of what you expected to happen. 29 | 30 | **5. Video or screenshots** 31 | 32 | If applicable, add a video or screenshots to help explain the bug. 33 | 34 | **6. Browser details** 35 | 36 | If applicable, let us know which OS, browser and browser version etc you're using. 37 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/one_feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Motion One: Feature request" 3 | about: Propose a feature for Motion One 4 | title: "[Feature] " 5 | labels: Feature 6 | assignees: "" 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | 8 | # testing 9 | coverage 10 | 11 | # next.js 12 | .next/ 13 | out/ 14 | build 15 | dist/ 16 | lib/ 17 | types/ 18 | !packages/types 19 | 20 | # misc 21 | .DS_Store 22 | *.pem 23 | 24 | # debug 25 | npm-debug.log* 26 | yarn-debug.log* 27 | yarn-error.log* 28 | lerna-debug.log 29 | 30 | # local env files 31 | .env.local 32 | .env.development.local 33 | .env.test.local 34 | .env.production.local 35 | 36 | # turbo 37 | .turbo 38 | 39 | tsconfig.tsbuildinfo 40 | app/js 41 | 42 | # Editor bundle 43 | Archive.zip 44 | 45 | # VSC Settings 46 | .vscode/settings.json 47 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | tasks: 2 | - init: yarn install && yarn run build 3 | vscode: 4 | extensions: ["esbenp.prettier-vscode"] 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | dist -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Matt Perry 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Motion One logo 2 | 3 | # Motion One 4 | 5 | This is the Motion One monorepo. It contains the source code for all Motion One libraries. 6 | 7 | ## 🕵️‍♂️ Source code 8 | 9 | - [`motion`](https://github.com/motiondivision/motionone/tree/main/packages/motion): The main entry point for Motion One. 10 | - [`@motionone/animation`](https://github.com/motiondivision/motionone/tree/main/packages/animation): A minimal, focused polyfill for WAAPI. 11 | - [`@motionone/dom`](https://github.com/motiondivision/motionone/tree/main/packages/dom): DOM-specific APIs like `animate` and `scroll`. 12 | - [`@motionone/easing`](https://github.com/motiondivision/motionone/tree/main/packages/easing): JavaScript implementations of web easing functions. 13 | - [`@motionone/generators`](https://github.com/motiondivision/motionone/tree/main/packages/generators): Keyframe generators like `spring` and `glide`. 14 | - [`@motionone/types`](https://github.com/motiondivision/motionone/tree/main/packages/types): Shared types for Motion One packages. 15 | - [`@motionone/utils`](https://github.com/motiondivision/motionone/tree/main/packages/utils): Shared utility functions across Motion One packages. 16 | 17 | ## 🛠 DevTools 18 | 19 | Create Motion One and CSS animations faster than ever with [Motion DevTools](https://motion.dev/tools). 20 | 21 | ## 📚 Documentation 22 | 23 | Full docs are available at [motion.dev](https://motion.dev). 24 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "10.18.0", 3 | "npmClient": "yarn", 4 | "useWorkspaces": true, 5 | "packages": [ 6 | "packages/*" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "motionone-monorepo", 3 | "version": "0.0.0", 4 | "private": true, 5 | "workspaces": { 6 | "packages": [ 7 | "playgrounds/*", 8 | "packages/*" 9 | ] 10 | }, 11 | "scripts": { 12 | "build": "turbo run build", 13 | "dev": "turbo run dev", 14 | "lint": "turbo run lint", 15 | "test": "turbo run test", 16 | "measure": "turbo run measure", 17 | "format": "prettier --write '**/*.{ts,tsx,.md}'", 18 | "new-prepare": "turbo run build test measure lint", 19 | "new": "npm run new-prepare && lerna publish from-package", 20 | "new-alpha": "npm run new-prepare && lerna publish from-package --canary --preid alpha", 21 | "new-beta": "npm run new-prepare && lerna publish from-package --canary --preid beta", 22 | "deploy": "turbo run build test measure lint deploy" 23 | }, 24 | "devDependencies": { 25 | "@rollup/plugin-node-resolve": "^13.0.6", 26 | "@rollup/plugin-replace": "^5.0.1", 27 | "@testing-library/dom": "^9.3.4", 28 | "@testing-library/jest-dom": "^6.2.0", 29 | "@types/jest": "29", 30 | "bundlesize2": "^0.0.31", 31 | "concurrently": "^7.3.0", 32 | "jest": "^29", 33 | "lerna": "^4.0.0", 34 | "prettier": "^2.5.1", 35 | "rimraf": "^5.0.1", 36 | "rollup": "^4.9.4", 37 | "rollup-plugin-terser": "^7.0.2", 38 | "ts-jest": "^29", 39 | "turbo": "^1.1.4", 40 | "typescript": "^5.4.0", 41 | "webpack": "5", 42 | "webpack-cli": "^4.9.1" 43 | }, 44 | "packageManager": "yarn@1.22.17", 45 | "dependencies": { 46 | "jest-environment-jsdom": "^29" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /packages/animation/.npmignore: -------------------------------------------------------------------------------- 1 | src/** 2 | .turbo 3 | tsconfig.json 4 | rollup.config.js 5 | README.md 6 | coverage/** 7 | jest.* -------------------------------------------------------------------------------- /packages/animation/README.md: -------------------------------------------------------------------------------- 1 | # `@motionone/animation` 2 | 3 | A simple number-only semi-implementation of WAAPI. 4 | 5 | ## 📚 Documentation 6 | 7 | Full docs for Motion One available at [motion.dev](https://motion.dev). 8 | -------------------------------------------------------------------------------- /packages/animation/jest.config.js: -------------------------------------------------------------------------------- 1 | const baseConfig = require("config/jest.config") 2 | 3 | /** @type {import('@jest/types').Config.InitialOptions} */ 4 | const config = { 5 | ...baseConfig, 6 | setupFilesAfterEnv: ["/../../config/jest.setup.ts"], 7 | } 8 | 9 | module.exports = config 10 | -------------------------------------------------------------------------------- /packages/animation/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@motionone/animation", 3 | "version": "10.18.0", 4 | "description": "A semi-polyfill WAAPI animation.", 5 | "license": "MIT", 6 | "author": "Matt Perry", 7 | "main": "dist/index.cjs.js", 8 | "module": "dist/index.es.js", 9 | "types": "types/index.d.ts", 10 | "sideEffects": false, 11 | "scripts": { 12 | "build": "rimraf lib dist types && tsc -p . && rollup -c", 13 | "test": "jest --coverage --config jest.config.js", 14 | "dev": "concurrently -c blue,red -n tsc,rollup --kill-others \"tsc --watch -p . --preserveWatchOutput\" \"rollup --c --watch --no-watch.clearScreen\"", 15 | "measure": "bundlesize" 16 | }, 17 | "dependencies": { 18 | "@motionone/easing": "^10.18.0", 19 | "@motionone/types": "^10.17.1", 20 | "@motionone/utils": "^10.18.0", 21 | "tslib": "^2.3.1" 22 | }, 23 | "bundlesize": [ 24 | { 25 | "path": "./dist/size-index.js", 26 | "maxSize": "1.6 kB" 27 | } 28 | ], 29 | "gitHead": "1c67c845fb4032c9d27f3761094939b30b759f9e" 30 | } 31 | -------------------------------------------------------------------------------- /packages/animation/rollup.config.js: -------------------------------------------------------------------------------- 1 | const { createDistBuild, createSizeBuild } = require("config/rollup.config") 2 | const pkg = require("./package.json") 3 | 4 | const sizeBundles = [["index.js", "size-index.js"]].map(([input, output]) => 5 | createSizeBuild({ input: `lib/${input}`, output: `dist/${output}` }, pkg) 6 | ) 7 | 8 | module.exports = [...createDistBuild(pkg), ...sizeBundles] 9 | -------------------------------------------------------------------------------- /packages/animation/src/Animation.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AnimationControls, 3 | AnimationOptions, 4 | EasingFunction, 5 | } from "@motionone/types" 6 | import { 7 | isEasingGenerator, 8 | isEasingList, 9 | defaults, 10 | noopReturn, 11 | interpolate as createInterpolate, 12 | } from "@motionone/utils" 13 | import { getEasingFunction } from "./utils/easing" 14 | 15 | export class Animation implements Omit { 16 | private resolve?: (value: any) => void 17 | 18 | private reject?: (value: any) => void 19 | 20 | startTime: number | null = null 21 | 22 | private pauseTime: number | undefined 23 | 24 | private rate = 1 25 | 26 | private tick: (t: number) => void 27 | 28 | private t = 0 29 | 30 | private cancelTimestamp: number | null = null 31 | 32 | private frameRequestId?: number 33 | 34 | private easing: EasingFunction = noopReturn 35 | 36 | private duration: number = 0 37 | 38 | private totalDuration: number = 0 39 | 40 | private repeat: number = 0 41 | 42 | playState: AnimationPlayState = "idle" 43 | 44 | constructor( 45 | output: (v: number) => void, 46 | keyframes: number[] = [0, 1], 47 | { 48 | easing, 49 | duration: initialDuration = defaults.duration, 50 | delay = defaults.delay, 51 | endDelay = defaults.endDelay, 52 | repeat = defaults.repeat, 53 | offset, 54 | direction = "normal", 55 | autoplay = true, 56 | }: AnimationOptions = {} 57 | ) { 58 | easing = easing || defaults.easing 59 | 60 | if (isEasingGenerator(easing)) { 61 | const custom = easing.createAnimation(keyframes) 62 | easing = custom.easing 63 | keyframes = (custom.keyframes as number[]) || keyframes 64 | initialDuration = custom.duration || initialDuration 65 | } 66 | 67 | this.repeat = repeat 68 | 69 | this.easing = isEasingList(easing) ? noopReturn : getEasingFunction(easing) 70 | this.updateDuration(initialDuration) 71 | 72 | const interpolate = createInterpolate( 73 | keyframes, 74 | offset, 75 | isEasingList(easing) ? easing.map(getEasingFunction) : noopReturn 76 | ) 77 | 78 | this.tick = (timestamp: number) => { 79 | // TODO: Temporary fix for OptionsResolver typing 80 | delay = delay as number 81 | 82 | let t = 0 83 | if (this.pauseTime !== undefined) { 84 | t = this.pauseTime 85 | } else { 86 | t = (timestamp - this.startTime!) * this.rate 87 | } 88 | 89 | this.t = t 90 | 91 | // Convert to seconds 92 | t /= 1000 93 | 94 | // Rebase on delay 95 | t = Math.max(t - delay, 0) 96 | 97 | /** 98 | * If this animation has finished, set the current time 99 | * to the total duration. 100 | */ 101 | if (this.playState === "finished" && this.pauseTime === undefined) { 102 | t = this.totalDuration 103 | } 104 | 105 | /** 106 | * Get the current progress (0-1) of the animation. If t is > 107 | * than duration we'll get values like 2.5 (midway through the 108 | * third iteration) 109 | */ 110 | const progress = t / this.duration 111 | 112 | // TODO progress += iterationStart 113 | 114 | /** 115 | * Get the current iteration (0 indexed). For instance the floor of 116 | * 2.5 is 2. 117 | */ 118 | let currentIteration = Math.floor(progress) 119 | 120 | /** 121 | * Get the current progress of the iteration by taking the remainder 122 | * so 2.5 is 0.5 through iteration 2 123 | */ 124 | let iterationProgress = progress % 1.0 125 | 126 | if (!iterationProgress && progress >= 1) { 127 | iterationProgress = 1 128 | } 129 | 130 | /** 131 | * If iteration progress is 1 we count that as the end 132 | * of the previous iteration. 133 | */ 134 | iterationProgress === 1 && currentIteration-- 135 | 136 | /** 137 | * Reverse progress if we're not running in "normal" direction 138 | */ 139 | const iterationIsOdd = currentIteration % 2 140 | if ( 141 | direction === "reverse" || 142 | (direction === "alternate" && iterationIsOdd) || 143 | (direction === "alternate-reverse" && !iterationIsOdd) 144 | ) { 145 | iterationProgress = 1 - iterationProgress 146 | } 147 | 148 | const p = t >= this.totalDuration ? 1 : Math.min(iterationProgress, 1) 149 | const latest = interpolate(this.easing(p)) 150 | output(latest) 151 | 152 | const isAnimationFinished = 153 | this.pauseTime === undefined && 154 | (this.playState === "finished" || t >= this.totalDuration + endDelay) 155 | 156 | if (isAnimationFinished) { 157 | this.playState = "finished" 158 | this.resolve?.(latest) 159 | } else if (this.playState !== "idle") { 160 | this.frameRequestId = requestAnimationFrame(this.tick) 161 | } 162 | } 163 | 164 | if (autoplay) this.play() 165 | } 166 | 167 | finished = new Promise((resolve, reject) => { 168 | this.resolve = resolve 169 | this.reject = reject 170 | }) 171 | 172 | play() { 173 | const now = performance.now() 174 | this.playState = "running" 175 | 176 | if (this.pauseTime !== undefined) { 177 | this.startTime = now - this.pauseTime 178 | } else if (!this.startTime) { 179 | this.startTime = now 180 | } 181 | 182 | this.cancelTimestamp = this.startTime 183 | this.pauseTime = undefined 184 | this.frameRequestId = requestAnimationFrame(this.tick) 185 | } 186 | 187 | pause() { 188 | this.playState = "paused" 189 | this.pauseTime = this.t 190 | } 191 | 192 | finish() { 193 | this.playState = "finished" 194 | this.tick(0) 195 | } 196 | 197 | stop() { 198 | this.playState = "idle" 199 | 200 | if (this.frameRequestId !== undefined) { 201 | cancelAnimationFrame(this.frameRequestId) 202 | } 203 | 204 | this.reject?.(false) 205 | } 206 | 207 | cancel() { 208 | this.stop() 209 | this.tick(this.cancelTimestamp!) 210 | } 211 | 212 | reverse() { 213 | this.rate *= -1 214 | } 215 | 216 | commitStyles() {} 217 | 218 | private updateDuration(duration: number) { 219 | this.duration = duration 220 | this.totalDuration = duration * (this.repeat + 1) 221 | } 222 | 223 | get currentTime() { 224 | return this.t 225 | } 226 | 227 | set currentTime(t: number) { 228 | if (this.pauseTime !== undefined || this.rate === 0) { 229 | this.pauseTime = t 230 | } else { 231 | this.startTime = performance.now() - t / this.rate 232 | } 233 | } 234 | 235 | get playbackRate() { 236 | return this.rate 237 | } 238 | 239 | set playbackRate(rate) { 240 | this.rate = rate 241 | } 242 | } 243 | -------------------------------------------------------------------------------- /packages/animation/src/index.ts: -------------------------------------------------------------------------------- 1 | export { Animation } from "./Animation" 2 | export * from "./utils/easing" 3 | -------------------------------------------------------------------------------- /packages/animation/src/utils/__tests__/easing.test.ts: -------------------------------------------------------------------------------- 1 | import { getEasingFunction } from "../easing" 2 | import { cubicBezier, steps } from "@motionone/easing" 3 | 4 | const namedEasings = { 5 | ease: cubicBezier(0.25, 0.1, 0.25, 1.0), 6 | "ease-in": cubicBezier(0.42, 0.0, 1.0, 1.0), 7 | "ease-in-out": cubicBezier(0.42, 0.0, 0.58, 1.0), 8 | "ease-out": cubicBezier(0.0, 0.0, 0.58, 1.0), 9 | } 10 | 11 | describe("getEasingFunction", () => { 12 | test("Correctly returns the correct easing function for the given definition", () => { 13 | expect(getEasingFunction("ease")(0.5)).toEqual(namedEasings.ease(0.5)) 14 | expect(getEasingFunction("ease-in")(0.5)).toEqual( 15 | namedEasings["ease-in"](0.5) 16 | ) 17 | expect(getEasingFunction("ease-in-out")(0.5)).toEqual( 18 | namedEasings["ease-in-out"](0.5) 19 | ) 20 | expect(getEasingFunction("ease-out")(0.5)).toEqual( 21 | namedEasings["ease-out"](0.5) 22 | ) 23 | expect(getEasingFunction((v: number) => v * 2)(0.5)).toEqual(1) 24 | expect(getEasingFunction([0.2, 0.0, 0.4, 1.0])(0.5)).toEqual( 25 | cubicBezier(0.2, 0.0, 0.4, 1.0)(0.5) 26 | ) 27 | expect(getEasingFunction("steps(2, end)")(0.5)).toEqual( 28 | steps(2, "end")(0.5) 29 | ) 30 | expect(getEasingFunction("steps(5, start)")(0.5)).toEqual( 31 | steps(5, "start")(0.5) 32 | ) 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /packages/animation/src/utils/easing.ts: -------------------------------------------------------------------------------- 1 | import type { Easing, EasingFunction } from "@motionone/types" 2 | import { cubicBezier, steps } from "@motionone/easing" 3 | import { noopReturn, isFunction, isCubicBezier } from "@motionone/utils" 4 | 5 | const namedEasings = { 6 | ease: cubicBezier(0.25, 0.1, 0.25, 1.0), 7 | "ease-in": cubicBezier(0.42, 0.0, 1.0, 1.0), 8 | "ease-in-out": cubicBezier(0.42, 0.0, 0.58, 1.0), 9 | "ease-out": cubicBezier(0.0, 0.0, 0.58, 1.0), 10 | } 11 | 12 | const functionArgsRegex = /\((.*?)\)/ 13 | 14 | export function getEasingFunction( 15 | definition: Easing | EasingFunction 16 | ): EasingFunction { 17 | // If already an easing function, return 18 | if (isFunction(definition)) return definition 19 | 20 | // If an easing curve definition, return bezier function 21 | if (isCubicBezier(definition)) return cubicBezier(...definition) 22 | 23 | // If we have a predefined easing function, return 24 | const namedEasing = namedEasings[definition as keyof typeof namedEasings] 25 | if (namedEasing) return namedEasing 26 | 27 | // If this is a steps function, attempt to create easing curve 28 | if (definition.startsWith("steps")) { 29 | const args = functionArgsRegex.exec(definition) 30 | if (args) { 31 | const argsArray = args[1].split(",") 32 | return steps(parseFloat(argsArray[0]), argsArray[1].trim() as any) 33 | } 34 | } 35 | 36 | return noopReturn 37 | } 38 | -------------------------------------------------------------------------------- /packages/animation/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "config/ts-base.json", 3 | "$schema": "https://json.schemastore.org/tsconfig", 4 | "include": ["src/**/*.ts", "**/*.ts", "**/*.tsx"], 5 | "exclude": ["**/__tests__/*"], 6 | "compilerOptions": { 7 | "rootDir": "./src", 8 | "outDir": "./lib", 9 | "declarationDir": "./types" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/config/eslint-preset.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["next", "prettier"], 3 | settings: { 4 | next: { 5 | rootDir: [ 6 | "sites/web/", 7 | "packages/config/", 8 | "packages/dom/", 9 | "packages/motion/", 10 | ], 11 | }, 12 | }, 13 | rules: { 14 | "react/no-unescaped-entities": "off", 15 | "react-hooks/exhaustive-deps": "off", 16 | }, 17 | } 18 | -------------------------------------------------------------------------------- /packages/config/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('@jest/types').Config.InitialOptions} */ 2 | const config = { 3 | rootDir: "src", 4 | preset: "ts-jest", 5 | testEnvironment: "jsdom", 6 | collectCoverageFrom: [ 7 | "**/*.{js,jsx,ts,tsx}", 8 | "!**/node_modules/**", 9 | "!**/__tests__/**", 10 | ], 11 | testMatch: ["**/__tests__/**/*.test.(js|ts)?(x)"], 12 | coverageDirectory: "/../coverage", 13 | } 14 | 15 | module.exports = config 16 | -------------------------------------------------------------------------------- /packages/config/jest.setup.ts: -------------------------------------------------------------------------------- 1 | import { fireEvent, getByTestId } from "@testing-library/dom" 2 | import "@testing-library/jest-dom" 3 | 4 | class FakePointerEvent extends Event { 5 | pointerType: string 6 | constructor(type: any, props: any) { 7 | super(type, props) 8 | this.pointerType = props.pointerType || "mouse" 9 | } 10 | } 11 | 12 | ;(window as any).PointerEvent = FakePointerEvent 13 | 14 | export const click = (element: any) => fireEvent.click(element) 15 | 16 | export const pointerEnter = (element: any, type?: any) => { 17 | fireEvent.pointerEnter( 18 | element, 19 | !type 20 | ? undefined 21 | : new FakePointerEvent("pointerenter", { pointerType: type }) 22 | ) 23 | } 24 | 25 | export const pointerLeave = (element: any) => fireEvent.pointerLeave(element) 26 | export const pointerDown = (element: any) => fireEvent.pointerDown(element) 27 | export const pointerUp = (element: any) => fireEvent.pointerUp(element) 28 | export const focus = (element: any, testId: any) => 29 | getByTestId(element, testId).focus() 30 | export const blue = (element: any, testId: any) => 31 | getByTestId(element, testId).blur() 32 | -------------------------------------------------------------------------------- /packages/config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "config", 3 | "version": "10.18.0", 4 | "main": "index.js", 5 | "private": true, 6 | "license": "MIT", 7 | "files": [ 8 | "eslint-preset.js", 9 | "jest.config.js", 10 | "jest.setup.ts", 11 | "ts-base.json", 12 | "ts-react.json", 13 | "rollup.config.js", 14 | "waapi-polyfill.js" 15 | ], 16 | "dependencies": { 17 | "eslint-config-next": "^12.0.3", 18 | "eslint-config-prettier": "^8.3.0" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/config/rollup.config.js: -------------------------------------------------------------------------------- 1 | const resolve = require("@rollup/plugin-node-resolve").default 2 | const { terser } = require("rollup-plugin-terser") 3 | 4 | module.exports = { 5 | createDistBuild: (pkg) => { 6 | const external = [ 7 | ...Object.keys(pkg.dependencies || {}), 8 | ...Object.keys(pkg.peerDependencies || {}), 9 | ] 10 | 11 | return [ 12 | { 13 | input: "lib/index.js", 14 | output: ["cjs", "es"].map((format) => ({ 15 | dir: "dist", 16 | format, 17 | exports: "named", 18 | entryFileNames: "[name].[format].js", 19 | preserveModules: true, 20 | })), 21 | external, 22 | }, 23 | ] 24 | }, 25 | createSizeBuild: ({ input, output }, pkg, plugins = [], external = []) => ({ 26 | input, 27 | output: { 28 | format: "es", 29 | exports: "named", 30 | file: output, 31 | }, 32 | plugins: [resolve(), ...plugins, terser({ output: { comments: false } })], 33 | external: [...Object.keys(pkg.peerDependencies || {}), ...external], 34 | }), 35 | } 36 | -------------------------------------------------------------------------------- /packages/config/ts-base.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Default", 4 | "compilerOptions": { 5 | "target": "ES6", 6 | "module": "ESNext", 7 | "composite": false, 8 | "declaration": true, 9 | "declarationMap": true, 10 | "esModuleInterop": true, 11 | "inlineSources": false, 12 | "isolatedModules": true, 13 | "preserveWatchOutput": true, 14 | "allowJs": true, 15 | "strict": true, 16 | "moduleResolution": "node", 17 | "importHelpers": true, 18 | "allowSyntheticDefaultImports": true, 19 | "experimentalDecorators": true, 20 | "emitDecoratorMetadata": true, 21 | "noLib": false, 22 | "preserveConstEnums": true, 23 | "sourceMap": true, 24 | "baseUrl": "src", 25 | "strictNullChecks": true, 26 | "noImplicitAny": true, 27 | "noImplicitThis": true, 28 | "noImplicitUseStrict": false, 29 | "noUnusedLocals": true, 30 | "noUnusedParameters": true, 31 | "removeComments": false, 32 | "lib": ["es2017", "dom", "dom.iterable", "scripthost"], 33 | "skipLibCheck": true, 34 | "downlevelIteration": true, 35 | "forceConsistentCasingInFileNames": true 36 | }, 37 | "exclude": ["node_modules", "**/__tests__/*"] 38 | } 39 | -------------------------------------------------------------------------------- /packages/config/ts-react.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "React Library", 4 | "extends": "./ts-base.json", 5 | "include": ["src"], 6 | "exclude": ["node_modules", "types"], 7 | "compilerOptions": { 8 | "lib": ["ES2015"], 9 | "module": "ESNext", 10 | "rootDir": "src", 11 | "outDir": "dist", 12 | "jsx": "react" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/dom/.npmignore: -------------------------------------------------------------------------------- 1 | src/** 2 | .turbo 3 | tsconfig.json 4 | rollup.config.js 5 | README.md 6 | coverage/** 7 | jest.* 8 | -------------------------------------------------------------------------------- /packages/dom/README.md: -------------------------------------------------------------------------------- 1 | # `@motionone/dom` 2 | 3 | DOM bindings for Motion One. 4 | 5 | ## 📚 Documentation 6 | 7 | Full docs for Motion One available at [motion.dev](https://motion.dev). 8 | -------------------------------------------------------------------------------- /packages/dom/jest.config.js: -------------------------------------------------------------------------------- 1 | const baseConfig = require("config/jest.config") 2 | 3 | /** @type {import('@jest/types').Config.InitialOptions} */ 4 | const config = { 5 | ...baseConfig, 6 | setupFilesAfterEnv: ["/../../config/jest.setup.ts"], 7 | } 8 | 9 | module.exports = config 10 | -------------------------------------------------------------------------------- /packages/dom/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@motionone/dom", 3 | "version": "10.18.0", 4 | "description": "A tiny, performant animation library for the DOM", 5 | "license": "MIT", 6 | "author": "Matt Perry", 7 | "main": "dist/index.cjs.js", 8 | "module": "dist/index.es.js", 9 | "types": "types/index.d.ts", 10 | "sideEffects": false, 11 | "scripts": { 12 | "build": "rimraf lib dist types && tsc -p . && webpack --config webpack.config.js && rollup -c", 13 | "test": "jest --coverage --config jest.config.js", 14 | "dev": "concurrently -c blue,red -n tsc,rollup --kill-others \"tsc --watch -p . --preserveWatchOutput\" \"rollup --c --watch --no-watch.clearScreen\"", 15 | "measure": "bundlesize" 16 | }, 17 | "dependencies": { 18 | "@motionone/animation": "^10.18.0", 19 | "@motionone/generators": "^10.18.0", 20 | "@motionone/types": "^10.17.1", 21 | "@motionone/utils": "^10.18.0", 22 | "hey-listen": "^1.0.8", 23 | "tslib": "^2.3.1" 24 | }, 25 | "bundlesize": [ 26 | { 27 | "path": "./dist/size-animate.js", 28 | "maxSize": "4.1 kB" 29 | }, 30 | { 31 | "path": "./dist/size-animate-style.js", 32 | "maxSize": "3.4 kB" 33 | }, 34 | { 35 | "path": "./dist/size-timeline.js", 36 | "maxSize": "4.82 kB" 37 | }, 38 | { 39 | "path": "./dist/size-spring.js", 40 | "maxSize": "1.5 kB" 41 | }, 42 | { 43 | "path": "./dist/size-webpack-animate.js", 44 | "maxSize": "3.94 kB" 45 | }, 46 | { 47 | "path": "./dist/size-in-view.js", 48 | "maxSize": "0.45 kB" 49 | }, 50 | { 51 | "path": "./dist/size-scroll.js", 52 | "maxSize": "2.51 kB" 53 | }, 54 | { 55 | "path": "./dist/size-resize.js", 56 | "maxSize": "0.65 kB" 57 | } 58 | ], 59 | "gitHead": "1c67c845fb4032c9d27f3761094939b30b759f9e" 60 | } 61 | -------------------------------------------------------------------------------- /packages/dom/rollup.config.js: -------------------------------------------------------------------------------- 1 | const resolve = require("@rollup/plugin-node-resolve").default 2 | const replace = require("@rollup/plugin-replace").default 3 | const { createDistBuild, createSizeBuild } = require("config/rollup.config") 4 | const pkg = require("./package.json") 5 | 6 | const sizeBundles = [ 7 | ["animate/index.js", "size-animate.js"], 8 | ["animate/animate-style.js", "size-animate-style.js"], 9 | ["timeline/index.js", "size-timeline.js"], 10 | ["easing/spring/index.js", "size-spring.js"], 11 | ["gestures/in-view.js", "size-in-view.js"], 12 | ["gestures/scroll/index.js", "size-scroll.js"], 13 | ["gestures/resize/index.js", "size-resize.js"], 14 | ].map(([input, output]) => 15 | createSizeBuild({ input: `lib/${input}`, output: `dist/${output}` }, pkg) 16 | ) 17 | 18 | const replaceSettings = (env) => { 19 | const replaceConfig = env 20 | ? { 21 | "process.env.NODE_ENV": JSON.stringify(env), 22 | preventAssignment: false, 23 | } 24 | : { 25 | preventAssignment: false, 26 | } 27 | 28 | replaceConfig.__VERSION__ = `${pkg.version}` 29 | 30 | return replace(replaceConfig) 31 | } 32 | 33 | const umd = { 34 | input: "lib/index.js", 35 | output: { 36 | file: `dist/motion-umd.dev.js`, 37 | format: "umd", 38 | name: "Motion", 39 | exports: "named", 40 | }, 41 | plugins: [resolve(), replaceSettings("development")], 42 | } 43 | 44 | module.exports = [...createDistBuild(pkg), ...sizeBundles, umd] 45 | -------------------------------------------------------------------------------- /packages/dom/src/animate/__tests__/animate.test.ts: -------------------------------------------------------------------------------- 1 | import { animate } from ".." 2 | import { style } from "../style" 3 | import "config/waapi-polyfill" 4 | 5 | /** 6 | * TODO: All tests currently have to define at least two keyframes 7 | * because the polyfill doesn't support partial keyframes. 8 | */ 9 | const duration = 0.001 10 | 11 | describe("animate", () => { 12 | test("No type errors", async () => { 13 | const div = document.createElement("div") 14 | const animation = animate( 15 | div, 16 | { opacity: 0.6, x: 1, scale: 1, "--css-var": 2 }, 17 | { 18 | duration, 19 | x: {}, 20 | "--css-var": { 21 | direction: "alternate", 22 | }, 23 | direction: "alternate", 24 | easing: "steps(2, start)", 25 | offset: [0], 26 | } 27 | ) 28 | await animation.finished.then(() => { 29 | expect(true).toBe(true) 30 | }) 31 | }) 32 | 33 | test("Applies target keyframe when animation has finished", async () => { 34 | const div = document.createElement("div") 35 | const animation = animate( 36 | div, 37 | { opacity: 0.6 }, 38 | { duration, x: {}, "--css-var": {} } 39 | ) 40 | await animation.finished.then(() => { 41 | expect(div).toHaveStyle("opacity: 0.6") 42 | }) 43 | }) 44 | 45 | test("Applies final target keyframe when animation has finished", async () => { 46 | const div = document.createElement("div") 47 | const animation = animate(div, { opacity: [0.2, 0.5] }, { duration }) 48 | await animation.finished.then(() => { 49 | expect(div).toHaveStyle("opacity: 0.5") 50 | }) 51 | }) 52 | 53 | test("Applies transform template", async () => { 54 | const div = document.createElement("div") 55 | const animation = animate(div, { x: 1 }, { duration }) 56 | await animation.finished.then(() => { 57 | expect(div).toHaveStyle("transform: translateX(var(--motion-translateX))") 58 | }) 59 | }) 60 | 61 | test.skip("Can manually finish animation", async () => { 62 | const div = document.createElement("div") 63 | const animation = animate(div, { opacity: 0.5 }, { duration: 10 }) 64 | 65 | return new Promise((resolve) => { 66 | animation.finished.then(() => { 67 | expect(div).toHaveStyle("opacity: 0.5") 68 | resolve() 69 | }) 70 | animation.finish() 71 | }) 72 | }) 73 | 74 | test.skip("Can manually cancel animation", async () => { 75 | const div = document.createElement("div") 76 | div.style.opacity = "0.2" 77 | const animation = animate(div, { opacity: 0.5 }, { duration: 10 }) 78 | return new Promise((resolve) => { 79 | animation.finished.catch(() => { 80 | expect(div).toHaveStyle("opacity: 0.2") 81 | resolve() 82 | }) 83 | animation.cancel() 84 | }) 85 | }) 86 | 87 | test("currentTime sets and gets currentTime", async () => { 88 | const div = document.createElement("div") 89 | const animation = animate(div, { opacity: 0.5 }, { duration: 10 }) 90 | 91 | expect(animation.currentTime).toBe(0) 92 | animation.currentTime = 5 93 | expect(animation.currentTime).toBe(5) 94 | }) 95 | 96 | test("autoplay false pauses animation", async () => { 97 | const div = document.createElement("div") 98 | const animation = animate( 99 | div, 100 | { opacity: 0.5 }, 101 | { duration: 0.1, autoplay: false } 102 | ) 103 | let hasFinished = false 104 | 105 | animation.finished.then(() => { 106 | hasFinished = true 107 | }) 108 | 109 | await new Promise((resolve) => { 110 | setTimeout(() => { 111 | expect(hasFinished).toBe(false) 112 | resolve() 113 | }, 200) 114 | }) 115 | }) 116 | 117 | test("currentTime can be set to duration", async () => { 118 | const div = document.createElement("div") 119 | div.style.opacity = "0" 120 | const animation = animate(div, { opacity: 0.5 }, { duration: 1 }) 121 | animation.pause() 122 | animation.currentTime = 1 123 | 124 | return new Promise((resolve) => { 125 | setTimeout(() => { 126 | expect(div).toHaveStyle("opacity: 0.5") 127 | resolve() 128 | }, 50) 129 | }) 130 | }) 131 | 132 | test("duration gets the duration of the animation", async () => { 133 | const div = document.createElement("div") 134 | const animation = animate(div, { opacity: 0.5 }, { duration: 10 }) 135 | 136 | expect(animation.duration).toBe(10) 137 | }) 138 | 139 | test("Interrupt polyfilled transforms", async () => { 140 | const div = document.createElement("div") 141 | animate(div, { x: 300 }, { duration: 1 }) 142 | 143 | const promise = new Promise((resolve) => { 144 | setTimeout(() => { 145 | const animation = animate(div, { x: 0 }, { duration: 1 }) 146 | setTimeout(() => { 147 | animation.stop() 148 | resolve(style.get(div, "--motion-translateX")) 149 | }, 50) 150 | }, 100) 151 | }) 152 | 153 | return expect(promise).resolves.not.toBe("0px") 154 | }) 155 | 156 | test("Split transforms support other units", async () => { 157 | const div = document.createElement("div") 158 | const animation = animate(div, { x: "10%" }, { duration }) 159 | await animation.finished.then(() => { 160 | expect(div).toHaveStyle("transform: translateX(var(--motion-translateX))") 161 | expect(style.get(div, "--motion-translateX")).toBe("10%") 162 | }) 163 | }) 164 | }) 165 | -------------------------------------------------------------------------------- /packages/dom/src/animate/create-animate.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AnimationFactory, 3 | AnimationOptionsWithOverrides, 4 | MotionKeyframesDefinition, 5 | } from "./types" 6 | import { invariant } from "hey-listen" 7 | import { animateStyle } from "./animate-style" 8 | import { getOptions } from "./utils/options" 9 | import { resolveElements } from "../utils/resolve-elements" 10 | import { withControls } from "./utils/controls" 11 | import { resolveOption } from "../utils/stagger" 12 | import { AnimationControls } from "@motionone/types" 13 | import { ElementOrSelector } from "../types" 14 | import type { Animation } from "@motionone/animation" 15 | 16 | export function createAnimate(AnimatePolyfill?: typeof Animation) { 17 | return function animate( 18 | elements: ElementOrSelector, 19 | keyframes: MotionKeyframesDefinition, 20 | options: AnimationOptionsWithOverrides = {} 21 | ): AnimationControls { 22 | elements = resolveElements(elements) 23 | const numElements = elements.length 24 | 25 | invariant(Boolean(numElements), "No valid element provided.") 26 | invariant(Boolean(keyframes), "No keyframes defined.") 27 | 28 | /** 29 | * Create and start new animations 30 | */ 31 | const animationFactories: AnimationFactory[] = [] 32 | for (let i = 0; i < numElements; i++) { 33 | const element = elements[i] 34 | 35 | for (const key in keyframes) { 36 | const valueOptions = getOptions(options, key) 37 | valueOptions.delay = resolveOption(valueOptions.delay, i, numElements) 38 | 39 | const animation = animateStyle( 40 | element, 41 | key, 42 | keyframes[key as keyof typeof keyframes]!, 43 | valueOptions, 44 | AnimatePolyfill 45 | ) 46 | 47 | animationFactories.push(animation) 48 | } 49 | } 50 | 51 | return withControls( 52 | animationFactories, 53 | options, 54 | /** 55 | * TODO: 56 | * If easing is set to spring or glide, duration will be dynamically 57 | * generated. Ideally we would dynamically generate this from 58 | * animation.effect.getComputedTiming().duration but this isn't 59 | * supported in iOS13 or our number polyfill. Perhaps it's possible 60 | * to Proxy animations returned from animateStyle that has duration 61 | * as a getter. 62 | */ 63 | options.duration 64 | ) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /packages/dom/src/animate/data.ts: -------------------------------------------------------------------------------- 1 | import type { ElementAnimationData } from "../types" 2 | import { MotionValue } from "@motionone/types" 3 | 4 | const data = new WeakMap() 5 | 6 | export function getAnimationData(element: Element): ElementAnimationData { 7 | if (!data.has(element)) { 8 | data.set(element, { 9 | transforms: [], 10 | values: new Map(), 11 | }) 12 | } 13 | 14 | return data.get(element)! 15 | } 16 | 17 | export function getMotionValue( 18 | motionValues: Map, 19 | name: string 20 | ) { 21 | if (!motionValues.has(name)) { 22 | motionValues.set(name, new MotionValue()) 23 | } 24 | 25 | return motionValues.get(name)! 26 | } 27 | -------------------------------------------------------------------------------- /packages/dom/src/animate/index.ts: -------------------------------------------------------------------------------- 1 | import { Animation } from "@motionone/animation" 2 | import { createAnimate } from "./create-animate" 3 | 4 | export const animate = createAnimate(Animation) 5 | -------------------------------------------------------------------------------- /packages/dom/src/animate/style.ts: -------------------------------------------------------------------------------- 1 | import { isCssVar } from "./utils/css-var" 2 | import { getStyleName } from "./utils/get-style-name" 3 | import { transformDefinitions } from "./utils/transforms" 4 | 5 | type MotionStyleKey = Exclude< 6 | keyof CSSStyleDeclaration, 7 | "length" | "parentRule" 8 | > 9 | 10 | export const style = { 11 | get: (element: Element, name: string): string | undefined => { 12 | name = getStyleName(name) 13 | let value: any = isCssVar(name) 14 | ? (element as HTMLElement).style.getPropertyValue(name) 15 | : getComputedStyle(element)[name as MotionStyleKey] 16 | 17 | // TODO Decide if value can be 0 18 | if (!value && value !== 0) { 19 | const definition = transformDefinitions.get(name) 20 | if (definition) value = definition.initialValue 21 | } 22 | 23 | return value as string | undefined 24 | }, 25 | set: (element: Element, name: string, value: string | number) => { 26 | name = getStyleName(name) 27 | 28 | if (isCssVar(name)) { 29 | ;(element as HTMLElement).style.setProperty(name, value as string) 30 | } else { 31 | ;(element as HTMLElement).style[name as MotionStyleKey] = value as any 32 | } 33 | }, 34 | } 35 | -------------------------------------------------------------------------------- /packages/dom/src/animate/types.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AnimationOptions, 3 | BasicAnimationControls, 4 | UnresolvedValueKeyframe, 5 | OptionResolver, 6 | } from "@motionone/types" 7 | import type { NextTime } from "../timeline/types" 8 | import { ValueKeyframe } from "@motionone/types" 9 | 10 | export type Omit = Pick> 11 | 12 | export interface CSSStyleDeclarationWithTransform 13 | extends Omit { 14 | x: number | string 15 | y: number | string 16 | z: number | string 17 | rotateX: number | string 18 | rotateY: number | string 19 | rotateZ: number | string 20 | scaleX: number 21 | scaleY: number 22 | scaleZ: number 23 | skewX: number | string 24 | skewY: number | string 25 | } 26 | 27 | export type StyleAnimationOptions = { 28 | [K in keyof CSSStyleDeclarationWithTransform]?: AnimationOptions 29 | } 30 | 31 | export type VariableAnimationOptions = { 32 | [key: `--${string}`]: AnimationOptions 33 | } 34 | 35 | export type AnimationOptionsWithOverrides = StyleAnimationOptions & 36 | VariableAnimationOptions & 37 | AnimationOptions 38 | 39 | export type ValueKeyframesDefinition = 40 | | ValueKeyframe 41 | | ValueKeyframe[] 42 | | UnresolvedValueKeyframe[] 43 | 44 | export type StyleKeyframes = { 45 | [K in keyof CSSStyleDeclarationWithTransform]?: 46 | | ValueKeyframe 47 | | ValueKeyframe[] 48 | } 49 | 50 | export type VariableKeyframes = { 51 | [key: `--${string}`]: ValueKeyframe | ValueKeyframe[] 52 | } 53 | 54 | export type MotionKeyframes = StyleKeyframes & VariableKeyframes 55 | 56 | export type StyleKeyframesDefinition = { 57 | [K in keyof CSSStyleDeclarationWithTransform]?: ValueKeyframesDefinition 58 | } 59 | 60 | export type VariableKeyframesDefinition = { 61 | [key: `--${string}`]: ValueKeyframesDefinition 62 | } 63 | 64 | export type MotionKeyframesDefinition = StyleKeyframesDefinition & 65 | VariableKeyframesDefinition 66 | 67 | export interface AnimationWithCommitStyles extends Animation { 68 | commitStyles: VoidFunction 69 | } 70 | 71 | export type AnimationListOptions = Omit< 72 | AnimationOptionsWithOverrides, 73 | "delay" | "direction" | "repeat" 74 | > & { 75 | delay?: number | OptionResolver 76 | at?: NextTime 77 | } 78 | 79 | export interface CssPropertyDefinition { 80 | syntax: `<${string}>` 81 | initialValue: string | number 82 | toDefaultUnit: (v: number) => string | number 83 | } 84 | 85 | export type CssPropertyDefinitionMap = { [key: string]: CssPropertyDefinition } 86 | 87 | export type AnimationFactory = () => BasicAnimationControls | undefined 88 | -------------------------------------------------------------------------------- /packages/dom/src/animate/utils/__tests__/controls.test.ts: -------------------------------------------------------------------------------- 1 | import { BasicAnimationControls } from "@motionone/types" 2 | import { withControls } from "../controls" 3 | 4 | interface TestAnimationOptions { 5 | startTime?: number 6 | currentTime?: number 7 | playbackRate?: number 8 | playState?: AnimationPlayState 9 | } 10 | 11 | function isPromise(p: Promise): p is Promise { 12 | if (typeof p === "object" && typeof p.then === "function") { 13 | return true 14 | } 15 | 16 | return false 17 | } 18 | 19 | function isFunction(f: any): f is VoidFunction { 20 | return typeof f === "function" 21 | } 22 | 23 | function testAnimation({ 24 | startTime = 0, 25 | currentTime = 0, 26 | playbackRate = 1, 27 | playState = "idle", 28 | }: TestAnimationOptions): BasicAnimationControls { 29 | return { 30 | play: () => {}, 31 | pause: () => {}, 32 | commitStyles: () => {}, 33 | cancel: () => {}, 34 | finish: () => {}, 35 | reverse: () => {}, 36 | stop: () => {}, 37 | playState, 38 | finished: new Promise(() => {}), 39 | startTime, 40 | currentTime, 41 | playbackRate, 42 | } as any 43 | } 44 | 45 | describe("Animation controls Proxy", () => { 46 | test("Returns duration from explicitly provided argument", () => { 47 | const controls = withControls( 48 | [() => testAnimation({ currentTime: 500 })], 49 | {}, 50 | 1 51 | ) 52 | expect(controls.duration).toBe(1) 53 | }) 54 | 55 | test("Returns currentTime in seconds", () => { 56 | const controls = withControls( 57 | [() => testAnimation({ currentTime: 500 })], 58 | {}, 59 | 1 60 | ) 61 | expect(controls.currentTime).toBe(0.5) 62 | }) 63 | 64 | test("Returns playbackRate", () => { 65 | const controls = withControls( 66 | [() => testAnimation({ playbackRate: 0.5 })], 67 | {}, 68 | 1 69 | ) 70 | expect(controls.playbackRate).toBe(0.5) 71 | }) 72 | 73 | test("Returns finished promise", () => { 74 | const controls = withControls([() => testAnimation({})], {}, 1) 75 | expect(isPromise(controls.finished)).toEqual(true) 76 | }) 77 | 78 | test("Returns supported functions", () => { 79 | const controls = withControls([() => testAnimation({})], {}, 1) 80 | expect(isFunction(controls.play)).toEqual(true) 81 | expect(isFunction(controls.pause)).toEqual(true) 82 | expect(isFunction(controls.commitStyles)).toEqual(true) 83 | expect(isFunction(controls.cancel)).toEqual(true) 84 | expect(isFunction(controls.stop)).toEqual(true) 85 | expect(isFunction(controls.finish)).toEqual(true) 86 | expect(isFunction(controls.reverse)).toEqual(true) 87 | }) 88 | 89 | test("Unsupported functions/values are undefined", () => { 90 | const controls = withControls([() => testAnimation({})], {}, 1) 91 | expect((controls as any).wooooo).toEqual(undefined) 92 | }) 93 | 94 | test("Reads playState", () => { 95 | const controls = withControls( 96 | [() => testAnimation({ playState: "finished" })], 97 | {}, 98 | 1 99 | ) 100 | expect(controls.playState).toEqual("finished") 101 | }) 102 | }) 103 | -------------------------------------------------------------------------------- /packages/dom/src/animate/utils/__tests__/css-var.test.ts: -------------------------------------------------------------------------------- 1 | import { registerCssVariable, registeredProperties } from "../css-var" 2 | 3 | describe("registerCssVariable", () => { 4 | test("it registers new CSS properties", () => { 5 | expect(registeredProperties).not.toContain("--motion-x") 6 | registerCssVariable("--motion-x") 7 | expect(registeredProperties).toContain("--motion-x") 8 | }) 9 | }) 10 | -------------------------------------------------------------------------------- /packages/dom/src/animate/utils/__tests__/easing.test.ts: -------------------------------------------------------------------------------- 1 | import { noopReturn } from "@motionone/utils" 2 | import { 3 | convertEasing, 4 | cubicBezierAsString, 5 | generateLinearEasingPoints, 6 | } from "../easing" 7 | 8 | describe("cubicBezierAsString", () => { 9 | test("Converts array to CSS bezier definition", () => { 10 | expect(cubicBezierAsString([0, 1, 2, 3])).toEqual( 11 | "cubic-bezier(0, 1, 2, 3)" 12 | ) 13 | }) 14 | }) 15 | 16 | describe("convertEasingList", () => { 17 | test("Converts bezier array to string", () => { 18 | expect( 19 | ["steps(5, start)", [0, 1, 2, 3], "linear"].map(convertEasing as any) 20 | ).toEqual(["steps(5, start)", "cubic-bezier(0, 1, 2, 3)", "linear"]) 21 | }) 22 | }) 23 | 24 | describe("generateLinearEasingPoints", () => { 25 | test("Converts easing function into string of points", () => { 26 | expect(generateLinearEasingPoints(noopReturn, 0.16)).toEqual( 27 | "0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1" 28 | ) 29 | expect(generateLinearEasingPoints(() => 0.5, 0.2)).toEqual( 30 | "0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5" 31 | ) 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /packages/dom/src/animate/utils/__tests__/get-unit.test.ts: -------------------------------------------------------------------------------- 1 | import { getUnitConverter } from "../get-unit" 2 | 3 | describe("getUnit", () => { 4 | test("Returns unit from value", () => { 5 | expect(getUnitConverter(["2"])(2)).toEqual(2) 6 | expect(getUnitConverter(["3.3"])(2)).toEqual(2) 7 | expect(getUnitConverter(["0.1px"])(2)).toEqual("2px") 8 | expect(getUnitConverter([".1px"])(2)).toEqual("2px") 9 | expect(getUnitConverter(["4%"])(2)).toEqual("2%") 10 | expect(getUnitConverter(["-4.5%"])(0.5)).toEqual("0.5%") 11 | }) 12 | }) 13 | -------------------------------------------------------------------------------- /packages/dom/src/animate/utils/__tests__/keyframes.test.ts: -------------------------------------------------------------------------------- 1 | import { hydrateKeyframes } from "../keyframes" 2 | 3 | describe("hydrateKeyframes", () => { 4 | test("It correctly hydrates null keyframes", () => { 5 | expect(hydrateKeyframes([0, 2], () => 0)).toEqual([0, 2]) 6 | expect(hydrateKeyframes([0, null, 10], () => 0)).toEqual([0, 0, 10]) 7 | expect(hydrateKeyframes([null, null, 10], () => "0.5")).toEqual([ 8 | "0.5", 9 | "0.5", 10 | 10, 11 | ]) 12 | expect(hydrateKeyframes([null, null, 10], () => "100")).toEqual([ 13 | "100", 14 | "100", 15 | 10, 16 | ]) 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /packages/dom/src/animate/utils/__tests__/options.test.ts: -------------------------------------------------------------------------------- 1 | import { getOptions } from "../options" 2 | 3 | describe("getOptions", () => { 4 | test("Allows options to be overridden on a value-specific basis", () => { 5 | expect( 6 | getOptions({ duration: 1, easing: "linear", x: { easing: "ease" } }, "x") 7 | ).toEqual({ duration: 1, easing: "ease", x: { easing: "ease" } }) 8 | expect( 9 | getOptions({ duration: 1, opacity: { duration: 0.0001 } }, "opacity") 10 | ).toEqual({ duration: 0.0001, opacity: { duration: 0.0001 } }) 11 | }) 12 | }) 13 | -------------------------------------------------------------------------------- /packages/dom/src/animate/utils/__tests__/style-string.test.ts: -------------------------------------------------------------------------------- 1 | import { createStyleString } from "../style-string" 2 | 3 | describe("createStyleString", () => { 4 | test("Creates a style string", () => { 5 | expect( 6 | createStyleString({ 7 | backgroundColor: "red", 8 | "--translateX": "100px", 9 | }) 10 | ).toEqual("background-color: red; --translateX: 100px; ") 11 | }) 12 | }) 13 | -------------------------------------------------------------------------------- /packages/dom/src/animate/utils/__tests__/transforms.test.ts: -------------------------------------------------------------------------------- 1 | import { addTransformToElement } from "../transforms" 2 | 3 | describe("addTransformToElement", () => { 4 | test("correctly adds transforms to transform template", () => { 5 | const element = document.createElement("div") 6 | addTransformToElement(element, "translateX") 7 | expect(element).toHaveStyle( 8 | "transform: translateX(var(--motion-translateX))" 9 | ) 10 | addTransformToElement(element, "scale") 11 | expect(element).toHaveStyle( 12 | "transform: translateX(var(--motion-translateX)) scale(var(--motion-scale))" 13 | ) 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /packages/dom/src/animate/utils/controls.ts: -------------------------------------------------------------------------------- 1 | import { defaults, noop, time } from "@motionone/utils" 2 | import type { AnimationControls, AnimationOptions } from "@motionone/types" 3 | import type { AnimationFactory, AnimationWithCommitStyles } from "../types" 4 | import { stopAnimation } from "./stop-animation" 5 | 6 | interface MotionState { 7 | animations: AnimationWithCommitStyles[] 8 | duration: number 9 | finished?: Promise 10 | options: AnimationOptions 11 | } 12 | 13 | const createAnimation = (factory: AnimationFactory) => factory() 14 | 15 | export const withControls = ( 16 | animationFactory: AnimationFactory[], 17 | options: AnimationOptions, 18 | duration = defaults.duration 19 | ) => { 20 | return new Proxy( 21 | { 22 | animations: animationFactory.map(createAnimation).filter(Boolean), 23 | duration, 24 | options, 25 | } as any, 26 | controls 27 | ) as AnimationControls 28 | } 29 | /** 30 | * TODO: 31 | * Currently this returns the first animation, ideally it would return 32 | * the first active animation. 33 | */ 34 | const getActiveAnimation = ( 35 | state: MotionState 36 | ): AnimationWithCommitStyles | undefined => state.animations[0] 37 | 38 | export const controls = { 39 | get: (target: MotionState, key: string) => { 40 | const activeAnimation = getActiveAnimation(target) 41 | 42 | switch (key) { 43 | case "duration": 44 | return target.duration 45 | case "currentTime": 46 | return time.s((activeAnimation?.[key] as number) || 0) 47 | case "playbackRate": 48 | case "playState": 49 | return activeAnimation?.[key] 50 | case "finished": 51 | if (!target.finished) { 52 | target.finished = Promise.all( 53 | target.animations.map(selectFinished) 54 | ).catch(noop) 55 | } 56 | return target.finished 57 | case "stop": 58 | return () => { 59 | target.animations.forEach((animation) => 60 | stopAnimation(animation as any) 61 | ) 62 | } 63 | case "forEachNative": 64 | /** 65 | * This is for internal use only, fire a callback for each 66 | * underlying animation. 67 | */ 68 | return ( 69 | callback: ( 70 | animation: AnimationWithCommitStyles, 71 | state: MotionState 72 | ) => void 73 | ) => { 74 | target.animations.forEach((animation) => callback(animation, target)) 75 | } 76 | default: 77 | return typeof activeAnimation?.[key as keyof typeof activeAnimation] === 78 | "undefined" 79 | ? undefined 80 | : () => 81 | target.animations.forEach((animation) => 82 | ( 83 | animation[ 84 | key as keyof AnimationWithCommitStyles 85 | ] as VoidFunction 86 | )() 87 | ) 88 | } 89 | }, 90 | set: (target: MotionState, key: string, value: number) => { 91 | switch (key) { 92 | case "currentTime": 93 | value = time.ms(value) 94 | // Fall-through 95 | case "playbackRate": 96 | for (let i = 0; i < target.animations.length; i++) { 97 | target.animations[i][key] = value 98 | } 99 | return true 100 | } 101 | return false 102 | }, 103 | } 104 | 105 | const selectFinished = (animation: Animation) => animation.finished 106 | -------------------------------------------------------------------------------- /packages/dom/src/animate/utils/css-var.ts: -------------------------------------------------------------------------------- 1 | import { transformDefinitions } from "./transforms" 2 | 3 | export const isCssVar = (name: string) => name.startsWith("--") 4 | 5 | export const registeredProperties = new Set() 6 | 7 | export function registerCssVariable(name: string) { 8 | if (registeredProperties.has(name)) return 9 | registeredProperties.add(name) 10 | 11 | try { 12 | const { syntax, initialValue } = transformDefinitions.has(name) 13 | ? transformDefinitions.get(name)! 14 | : ({} as any) 15 | 16 | ;(CSS as any).registerProperty({ 17 | name, 18 | inherits: false, 19 | syntax, 20 | initialValue, 21 | }) 22 | } catch (e) {} 23 | } 24 | -------------------------------------------------------------------------------- /packages/dom/src/animate/utils/easing.ts: -------------------------------------------------------------------------------- 1 | import type { BezierDefinition, Easing, EasingFunction } from "@motionone/types" 2 | import { defaults, isCubicBezier, isFunction, progress } from "@motionone/utils" 3 | import { supports } from "./feature-detection" 4 | 5 | // Create a linear easing point for every x second 6 | const resolution = 0.015 7 | 8 | export const generateLinearEasingPoints = ( 9 | easing: EasingFunction, 10 | duration: number 11 | ): string => { 12 | let points = "" 13 | const numPoints = Math.round(duration / resolution) 14 | 15 | for (let i = 0; i < numPoints; i++) { 16 | points += easing(progress(0, numPoints - 1, i)) + ", " 17 | } 18 | 19 | return points.substring(0, points.length - 2) 20 | } 21 | 22 | export const convertEasing = ( 23 | easing: Easing | EasingFunction, 24 | duration: number 25 | ): string => { 26 | if (isFunction(easing)) { 27 | return supports.linearEasing() 28 | ? `linear(${generateLinearEasingPoints(easing, duration)})` 29 | : (defaults.easing as string) 30 | } else { 31 | return isCubicBezier(easing) ? cubicBezierAsString(easing) : easing 32 | } 33 | } 34 | 35 | export const cubicBezierAsString = ([a, b, c, d]: BezierDefinition) => 36 | `cubic-bezier(${a}, ${b}, ${c}, ${d})` 37 | -------------------------------------------------------------------------------- /packages/dom/src/animate/utils/feature-detection.ts: -------------------------------------------------------------------------------- 1 | const testAnimation = ( 2 | keyframes: PropertyIndexedKeyframes, 3 | options?: KeyframeAnimationOptions 4 | ) => document.createElement("div").animate(keyframes, options) 5 | 6 | const featureTests = { 7 | cssRegisterProperty: () => 8 | typeof CSS !== "undefined" && 9 | Object.hasOwnProperty.call(CSS, "registerProperty"), 10 | waapi: () => Object.hasOwnProperty.call(Element.prototype, "animate"), 11 | partialKeyframes: () => { 12 | try { 13 | testAnimation({ opacity: [1] }) 14 | } catch (e) { 15 | return false 16 | } 17 | return true 18 | }, 19 | finished: () => 20 | Boolean(testAnimation({ opacity: [0, 1] }, { duration: 0.001 }).finished), 21 | linearEasing: () => { 22 | try { 23 | testAnimation({ opacity: 0 }, { easing: "linear(0, 1)" }) 24 | } catch (e) { 25 | return false 26 | } 27 | return true 28 | }, 29 | } 30 | 31 | const results = {} 32 | 33 | type FeatureTests = Record boolean> 34 | 35 | export const supports = {} as FeatureTests 36 | for (const key in featureTests) { 37 | supports[key as keyof typeof supports] = () => { 38 | if (results[key as keyof typeof results] === undefined) 39 | (results[key as keyof typeof results] as any) = 40 | featureTests[key as keyof typeof featureTests]() 41 | return results[key as keyof typeof results] 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /packages/dom/src/animate/utils/get-style-name.ts: -------------------------------------------------------------------------------- 1 | import { asTransformCssVar, isTransform, transformAlias } from "./transforms" 2 | 3 | export function getStyleName(key: string): string { 4 | if (transformAlias[key as keyof typeof transformAlias]) 5 | key = transformAlias[key as keyof typeof transformAlias] 6 | return isTransform(key) ? asTransformCssVar(key) : key 7 | } 8 | -------------------------------------------------------------------------------- /packages/dom/src/animate/utils/get-unit.ts: -------------------------------------------------------------------------------- 1 | import { UnresolvedValueKeyframe } from "@motionone/types" 2 | import { isString, noopReturn } from "@motionone/utils" 3 | import { CssPropertyDefinition } from "../types" 4 | 5 | export function getUnitConverter( 6 | keyframes: UnresolvedValueKeyframe[], 7 | definition?: CssPropertyDefinition 8 | ) { 9 | let toUnit = definition?.toDefaultUnit || noopReturn 10 | const finalKeyframe = keyframes[keyframes.length - 1] 11 | if (isString(finalKeyframe)) { 12 | const unit = finalKeyframe.match(/(-?[\d.]+)([a-z%]*)/)?.[2] || "" 13 | if (unit) toUnit = (value: number) => value + unit 14 | } 15 | 16 | return toUnit 17 | } 18 | -------------------------------------------------------------------------------- /packages/dom/src/animate/utils/keyframes.ts: -------------------------------------------------------------------------------- 1 | import type { UnresolvedValueKeyframe, ValueKeyframe } from "@motionone/types" 2 | 3 | export function hydrateKeyframes( 4 | keyframes: UnresolvedValueKeyframe[], 5 | readInitialValue: () => string | number 6 | ): ValueKeyframe[] { 7 | for (let i = 0; i < keyframes.length; i++) { 8 | if (keyframes[i] === null) { 9 | keyframes[i] = i ? keyframes[i - 1] : readInitialValue() 10 | } 11 | } 12 | 13 | return keyframes as ValueKeyframe[] 14 | } 15 | 16 | export const keyframesList = ( 17 | keyframes: UnresolvedValueKeyframe | UnresolvedValueKeyframe[] 18 | ): UnresolvedValueKeyframe[] => 19 | Array.isArray(keyframes) ? keyframes : [keyframes] 20 | -------------------------------------------------------------------------------- /packages/dom/src/animate/utils/options.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AnimationListOptions, 3 | AnimationOptionsWithOverrides, 4 | } from "../types" 5 | import type { AnimationOptions } from "@motionone/types" 6 | 7 | export const getOptions = ( 8 | options: AnimationOptionsWithOverrides | AnimationListOptions, 9 | key: string 10 | ): AnimationOptions => 11 | /** 12 | * TODO: Make test for this 13 | * Always return a new object otherwise delay is overwritten by results of stagger 14 | * and this results in no stagger 15 | */ 16 | options[key as any] ? { ...options, ...options[key as any] } : { ...options } 17 | -------------------------------------------------------------------------------- /packages/dom/src/animate/utils/stop-animation.ts: -------------------------------------------------------------------------------- 1 | import type { BasicAnimationControls } from "@motionone/types" 2 | 3 | export interface WithCommitStyles { 4 | commitStyles: VoidFunction 5 | cancel: VoidFunction 6 | } 7 | 8 | export function stopAnimation( 9 | animation?: BasicAnimationControls, 10 | needsCommit = true 11 | ) { 12 | if (!animation || animation.playState === "finished") return 13 | 14 | // Suppress error thrown by WAAPI 15 | try { 16 | if (animation.stop) { 17 | animation.stop() 18 | } else { 19 | needsCommit && animation.commitStyles() 20 | animation.cancel() 21 | } 22 | } catch (e) {} 23 | } 24 | -------------------------------------------------------------------------------- /packages/dom/src/animate/utils/style-object.ts: -------------------------------------------------------------------------------- 1 | import type { MotionKeyframes } from "../types" 2 | import { isNumber } from "@motionone/utils" 3 | import { 4 | asTransformCssVar, 5 | buildTransformTemplate, 6 | isTransform, 7 | transformAlias, 8 | transformDefinitions, 9 | } from "./transforms" 10 | 11 | export function createStyles(keyframes?: MotionKeyframes): any { 12 | const initialKeyframes: any = {} 13 | const transformKeys: string[] = [] 14 | 15 | for (let key in keyframes) { 16 | const value = keyframes[key as keyof typeof keyframes] 17 | if (isTransform(key)) { 18 | if (transformAlias[key]) key = transformAlias[key] 19 | transformKeys.push(key) 20 | key = asTransformCssVar(key) 21 | } 22 | 23 | let initialKeyframe = Array.isArray(value) ? value[0] : value 24 | 25 | /** 26 | * If this is a number and we have a default value type, convert the number 27 | * to this type. 28 | */ 29 | const definition = transformDefinitions.get(key) 30 | if (definition) { 31 | initialKeyframe = isNumber(value) 32 | ? definition.toDefaultUnit!(value) 33 | : (value as any) 34 | } 35 | 36 | initialKeyframes[key] = initialKeyframe 37 | } 38 | 39 | if (transformKeys.length) { 40 | initialKeyframes.transform = buildTransformTemplate(transformKeys) 41 | } 42 | 43 | return initialKeyframes 44 | } 45 | -------------------------------------------------------------------------------- /packages/dom/src/animate/utils/style-string.ts: -------------------------------------------------------------------------------- 1 | import type { MotionKeyframes } from "../types" 2 | import { createStyles } from "./style-object" 3 | 4 | const camelLetterToPipeLetter = (letter: string) => `-${letter.toLowerCase()}` 5 | const camelToPipeCase = (str: string) => 6 | str.replace(/[A-Z]/g, camelLetterToPipeLetter) 7 | 8 | export function createStyleString(target: MotionKeyframes = {}) { 9 | const styles = createStyles(target) 10 | let style = "" 11 | for (const key in styles) { 12 | style += key.startsWith("--") ? key : camelToPipeCase(key) 13 | style += `: ${styles[key]}; ` 14 | } 15 | 16 | return style 17 | } 18 | -------------------------------------------------------------------------------- /packages/dom/src/animate/utils/transforms.ts: -------------------------------------------------------------------------------- 1 | import type { CssPropertyDefinition, CssPropertyDefinitionMap } from "../types" 2 | import { addUniqueItem, noopReturn } from "@motionone/utils" 3 | import { getAnimationData } from "../data" 4 | 5 | /** 6 | * A list of all transformable axes. We'll use this list to generated a version 7 | * of each axes for each transform. 8 | */ 9 | export const axes = ["", "X", "Y", "Z"] 10 | 11 | /** 12 | * An ordered array of each transformable value. By default, transform values 13 | * will be sorted to this order. 14 | */ 15 | const order = ["translate", "scale", "rotate", "skew"] 16 | 17 | export const transformAlias = { 18 | x: "translateX", 19 | y: "translateY", 20 | z: "translateZ", 21 | } 22 | 23 | const rotation: CssPropertyDefinition = { 24 | syntax: "", 25 | initialValue: "0deg", 26 | toDefaultUnit: (v: number) => v + "deg", 27 | } 28 | 29 | const baseTransformProperties: CssPropertyDefinitionMap = { 30 | translate: { 31 | syntax: "", 32 | initialValue: "0px", 33 | toDefaultUnit: (v: number) => v + "px", 34 | }, 35 | rotate: rotation, 36 | scale: { 37 | syntax: "", 38 | initialValue: 1, 39 | toDefaultUnit: noopReturn, 40 | }, 41 | skew: rotation, 42 | } 43 | 44 | export const transformDefinitions = new Map() 45 | 46 | export const asTransformCssVar = (name: string) => `--motion-${name}` 47 | 48 | /** 49 | * Generate a list of every possible transform key 50 | */ 51 | const transforms = ["x", "y", "z"] 52 | order.forEach((name) => { 53 | axes.forEach((axis) => { 54 | transforms.push(name + axis) 55 | 56 | transformDefinitions.set( 57 | asTransformCssVar(name + axis), 58 | baseTransformProperties[name] 59 | ) 60 | }) 61 | }) 62 | 63 | /** 64 | * A function to use with Array.sort to sort transform keys by their default order. 65 | */ 66 | export const compareTransformOrder = (a: string, b: string) => 67 | transforms.indexOf(a) - transforms.indexOf(b) 68 | 69 | /** 70 | * Provide a quick way to check if a string is the name of a transform 71 | */ 72 | const transformLookup = new Set(transforms) 73 | export const isTransform = ( 74 | name: string 75 | ): name is keyof typeof transformAlias => transformLookup.has(name) 76 | 77 | export const addTransformToElement = (element: HTMLElement, name: string) => { 78 | // Map x to translateX etc 79 | if (transformAlias[name as keyof typeof transformAlias]) 80 | name = transformAlias[name as keyof typeof transformAlias] 81 | 82 | const { transforms } = getAnimationData(element) 83 | addUniqueItem(transforms, name) 84 | 85 | /** 86 | * TODO: An optimisation here could be to cache the transform in element data 87 | * and only update if this has changed. 88 | */ 89 | element.style.transform = buildTransformTemplate(transforms) 90 | } 91 | 92 | export const buildTransformTemplate = (transforms: string[]): string => 93 | transforms 94 | .sort(compareTransformOrder) 95 | .reduce(transformListToString, "") 96 | .trim() 97 | 98 | const transformListToString = (template: string, name: string) => 99 | `${template} ${name}(var(${asTransformCssVar(name)}))` 100 | -------------------------------------------------------------------------------- /packages/dom/src/easing/__tests__/generator-easing.test.ts: -------------------------------------------------------------------------------- 1 | import { createGeneratorEasing } from "../create-generator-easing" 2 | import { mix, progress } from "@motionone/utils" 3 | import { MotionValue } from "@motionone/types" 4 | 5 | const pxKeyframes = [ 6 | "50px", 7 | "55px", 8 | "60px", 9 | "65px", 10 | "70px", 11 | "75px", 12 | "80px", 13 | "85px", 14 | "90px", 15 | "95px", 16 | "100px", 17 | ] 18 | 19 | const testGenerator = createGeneratorEasing( 20 | ({ duration, from, to }: any) => 21 | (t: number) => ({ 22 | current: mix(from, to, progress(0, duration, t)), 23 | target: to, 24 | done: t >= duration, 25 | hasReachedTarget: t >= duration * 0.5, 26 | }) 27 | ) 28 | 29 | describe("createGeneratorEasing", () => { 30 | test("Returns animation settings if shouldGenerate is set to false", () => { 31 | const generator = testGenerator({ duration: 1000 }) 32 | const { easing, duration, keyframes } = generator.createAnimation( 33 | [0, 100], 34 | false 35 | ) 36 | expect(easing).toEqual("ease") 37 | expect(duration).toEqual(0.5) 38 | expect(keyframes).toBeUndefined() 39 | }) 40 | 41 | test("Returns animation settings if values are not numerical", () => { 42 | const generator = testGenerator({ duration: 1000 }) 43 | const { easing, duration, keyframes } = generator.createAnimation( 44 | ["rgba(255, 255, 255)", "rgba(0, 0, 0)"], 45 | false 46 | ) 47 | expect(easing).toEqual("ease") 48 | expect(duration).toEqual(0.5) 49 | expect(keyframes).toBeUndefined() 50 | }) 51 | 52 | test("Returns animation settings if only 1 keyframe is provided and no readInitialValue is provided", () => { 53 | const generator = testGenerator({ duration: 1000 }) 54 | const { easing, duration, keyframes } = generator.createAnimation( 55 | [100], 56 | false 57 | ) 58 | expect(easing).toEqual("ease") 59 | expect(duration).toEqual(0.5) 60 | expect(keyframes).toBeUndefined() 61 | }) 62 | 63 | test("Returns animation settings if first keyframe is null and no readInitialValue is provided", () => { 64 | const generator = testGenerator({ duration: 1000 }) 65 | const { easing, duration, keyframes } = generator.createAnimation( 66 | [null, 100], 67 | false 68 | ) 69 | expect(easing).toEqual("ease") 70 | expect(duration).toEqual(0.5) 71 | expect(keyframes).toBeUndefined() 72 | }) 73 | 74 | test("Returns generated keyframes if provided 2 numerical keyframes", () => { 75 | const generator = testGenerator({ duration: 100 }) 76 | const { easing, duration, keyframes } = generator.createAnimation([50, 100]) 77 | expect(easing).toEqual("linear") 78 | expect(duration).toEqual(0.1) 79 | expect(keyframes).toEqual([50, 55, 60, 65, 70, 75, 80, 85, 90, 95, 100]) 80 | }) 81 | 82 | test("Returns generated keyframes if provided 2 unit keyframes", () => { 83 | const generator = testGenerator({ duration: 100 }) 84 | const { easing, duration, keyframes } = generator.createAnimation([ 85 | "50px", 86 | "100px", 87 | ]) 88 | expect(easing).toEqual("linear") 89 | expect(duration).toEqual(0.1) 90 | expect(keyframes).toEqual(pxKeyframes) 91 | }) 92 | 93 | test("Returns generated keyframes if provided 1 keyframe and a getOrigin function", () => { 94 | const generator = testGenerator({ duration: 100 }) 95 | const { easing, duration, keyframes } = generator.createAnimation( 96 | ["100px"], 97 | true, 98 | () => "50px" 99 | ) 100 | expect(easing).toEqual("linear") 101 | expect(duration).toEqual(0.1) 102 | expect(keyframes).toEqual(pxKeyframes) 103 | }) 104 | 105 | test("Returns generated keyframes if provided a null origin and a getOrigin function", () => { 106 | const generator = testGenerator({ duration: 100 }) 107 | const { easing, duration, keyframes } = generator.createAnimation( 108 | [null, "100px"], 109 | true, 110 | () => "50px" 111 | ) 112 | expect(easing).toEqual("linear") 113 | expect(duration).toEqual(0.1) 114 | expect(keyframes).toEqual(pxKeyframes) 115 | }) 116 | 117 | test("Uses existing generator if provided 1 keyframe and motion value", () => { 118 | const generator = testGenerator({ duration: 100 }) 119 | const x = new MotionValue() 120 | 121 | x.animation = { currentTime: 50 } as any 122 | x.generator = (t: number) => ({ 123 | current: mix(0, 100, progress(0, 100, t)), 124 | target: 100, 125 | done: t >= 100, 126 | hasReachedTarget: t >= 100 * 0.5, 127 | }) 128 | x.generatorStartTime = 0 129 | 130 | const { easing, duration, keyframes } = generator.createAnimation( 131 | [null, "100px"], 132 | true, 133 | () => "20px", 134 | "x", 135 | x 136 | ) 137 | expect(easing).toEqual("linear") 138 | expect(duration).toEqual(0.1) 139 | expect(keyframes).toEqual(pxKeyframes) 140 | }) 141 | }) 142 | -------------------------------------------------------------------------------- /packages/dom/src/easing/create-generator-easing.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CustomAnimationSettings, 3 | EasingGenerator, 4 | AnimationGenerator, 5 | AnimationGeneratorFactory, 6 | } from "@motionone/types" 7 | import type { KeyframesMetadata } from "@motionone/generators" 8 | import { pregenerateKeyframes } from "@motionone/generators" 9 | import { isNumber, isString, noopReturn } from "@motionone/utils" 10 | import { getUnitConverter } from "../animate/utils/get-unit" 11 | import { calcGeneratorVelocity } from "@motionone/generators" 12 | import { transformDefinitions } from "../animate/utils/transforms" 13 | import { getStyleName } from "../animate/utils/get-style-name" 14 | 15 | type ToUnit = (value: number) => number | string 16 | 17 | function canGenerate(value: any): value is number { 18 | return isNumber(value) && !isNaN(value) 19 | } 20 | 21 | function getAsNumber(value: string | number | null): number | null { 22 | return isString(value) ? parseFloat(value) : value 23 | } 24 | 25 | export function createGeneratorEasing( 26 | createGenerator: AnimationGeneratorFactory 27 | ) { 28 | const keyframesCache = new WeakMap() 29 | 30 | return (options: Options = {} as Options): EasingGenerator => { 31 | const generatorCache = new Map() 32 | 33 | const getGenerator = ( 34 | from = 0, 35 | to = 100, 36 | velocity = 0, 37 | isScale = false 38 | ) => { 39 | const key = `${from}-${to}-${velocity}-${isScale}` 40 | if (!generatorCache.has(key)) { 41 | generatorCache.set( 42 | key, 43 | createGenerator({ 44 | from, 45 | to, 46 | velocity, 47 | ...options, 48 | }) 49 | ) 50 | } 51 | 52 | return generatorCache.get(key) as AnimationGenerator 53 | } 54 | 55 | const getKeyframes = (generator: AnimationGenerator, toUnit?: ToUnit) => { 56 | if (!keyframesCache.has(generator)) { 57 | keyframesCache.set(generator, pregenerateKeyframes(generator, toUnit)) 58 | } 59 | 60 | return keyframesCache.get(generator) as KeyframesMetadata 61 | } 62 | 63 | return { 64 | createAnimation: ( 65 | keyframes, 66 | shouldGenerate = true, 67 | getOrigin, 68 | name, 69 | motionValue 70 | ) => { 71 | let settings: CustomAnimationSettings | undefined 72 | 73 | let origin: number | undefined | null 74 | let target: number | undefined | null 75 | let velocity = 0 76 | let toUnit: undefined | ((value: number) => string | number) = 77 | noopReturn 78 | 79 | const numKeyframes = keyframes.length 80 | 81 | /** 82 | * If we should generate an animation for this value, run some preperation 83 | * like resolving target/origin, finding a unit (if any) and determine if 84 | * it is actually possible to generate. 85 | */ 86 | if (shouldGenerate) { 87 | toUnit = getUnitConverter( 88 | keyframes, 89 | name ? transformDefinitions.get(getStyleName(name)) : undefined 90 | ) 91 | 92 | const targetDefinition = keyframes[numKeyframes - 1] 93 | 94 | target = getAsNumber(targetDefinition) 95 | 96 | if (numKeyframes > 1 && keyframes[0] !== null) { 97 | /** 98 | * If we have multiple keyframes, take the initial keyframe as the origin. 99 | */ 100 | origin = getAsNumber(keyframes[0]) 101 | } else { 102 | const prevGenerator = motionValue?.generator 103 | 104 | /** 105 | * If we have an existing generator for this value we can use it to resolve 106 | * the animation's current value and velocity. 107 | */ 108 | if (prevGenerator) { 109 | /** 110 | * If we have a generator for this value we can use it to resolve 111 | * the animations's current value and velocity. 112 | */ 113 | const { animation, generatorStartTime } = motionValue! 114 | 115 | const startTime = animation?.startTime || generatorStartTime || 0 116 | const currentTime = 117 | animation?.currentTime || performance.now() - startTime 118 | const prevGeneratorCurrent = prevGenerator(currentTime).current 119 | 120 | origin = prevGeneratorCurrent 121 | 122 | velocity = calcGeneratorVelocity( 123 | (t: number) => prevGenerator(t).current, 124 | currentTime, 125 | prevGeneratorCurrent 126 | ) 127 | } else if (getOrigin) { 128 | /** 129 | * As a last resort, read the origin from the DOM. 130 | */ 131 | origin = getAsNumber(getOrigin()) 132 | } 133 | } 134 | } 135 | 136 | /** 137 | * If we've determined it is possible to generate an animation, do so. 138 | */ 139 | if (canGenerate(origin) && canGenerate(target)) { 140 | const generator = getGenerator( 141 | origin, 142 | target, 143 | velocity, 144 | name?.includes("scale") 145 | ) 146 | settings = { ...getKeyframes(generator, toUnit), easing: "linear" } 147 | 148 | // TODO Add test for this 149 | if (motionValue) { 150 | motionValue.generator = generator 151 | motionValue.generatorStartTime = performance.now() 152 | } 153 | } 154 | 155 | /** 156 | * If by now we haven't generated a set of keyframes, create a generic generator 157 | * based on the provided props that animates from 0-100 to fetch a rough 158 | * "overshootDuration" - the moment when the generator first hits the animation target. 159 | * Then return animation settings that will run a normal animation for that duration. 160 | */ 161 | if (!settings) { 162 | const keyframesMetadata = getKeyframes(getGenerator(0, 100)) 163 | 164 | settings = { 165 | easing: "ease", 166 | duration: keyframesMetadata.overshootDuration, 167 | } 168 | } 169 | 170 | return settings 171 | }, 172 | } 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /packages/dom/src/easing/glide/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | glide as createGlideGenerator, 3 | GlideOptions, 4 | } from "@motionone/generators" 5 | export type { GlideOptions } 6 | import { createGeneratorEasing } from "../create-generator-easing" 7 | 8 | export const glide = createGeneratorEasing(createGlideGenerator) 9 | -------------------------------------------------------------------------------- /packages/dom/src/easing/spring/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | spring as createSpringGenerator, 3 | SpringOptions, 4 | } from "@motionone/generators" 5 | export type { SpringOptions } 6 | import { createGeneratorEasing } from "../create-generator-easing" 7 | 8 | export const spring = createGeneratorEasing( 9 | createSpringGenerator 10 | ) 11 | -------------------------------------------------------------------------------- /packages/dom/src/gestures/__tests__/in-view.test.ts: -------------------------------------------------------------------------------- 1 | import { inView } from "../in-view" 2 | import { getActiveObserver } from "./mock-intersection-observer" 3 | 4 | describe("view", () => { 5 | test("Fires onEnter when element enters viewport", async () => { 6 | const onEnter = () => () => {} 7 | const mockedOnEnter = jest.fn(onEnter) 8 | const element = document.createElement("div") 9 | inView(element, mockedOnEnter) 10 | 11 | expect(getActiveObserver()).toBeTruthy() 12 | getActiveObserver()?.([{ target: element, isIntersecting: true }]) 13 | 14 | expect(mockedOnEnter).toBeCalledTimes(1) 15 | getActiveObserver()?.([{ target: element, isIntersecting: false }]) 16 | getActiveObserver()?.([{ target: element, isIntersecting: true }]) 17 | expect(mockedOnEnter).toBeCalledTimes(2) 18 | }) 19 | 20 | test("Fires onLeave when element leaves viewport", async () => { 21 | const onLeave = jest.fn() 22 | const onEnter = () => onLeave 23 | const element = document.createElement("div") 24 | inView(element, onEnter) 25 | 26 | expect(getActiveObserver()).toBeTruthy() 27 | getActiveObserver()?.([{ target: element, isIntersecting: true }]) 28 | getActiveObserver()?.([{ target: element, isIntersecting: false }]) 29 | 30 | expect(onLeave).toBeCalledTimes(1) 31 | }) 32 | 33 | test("Stops observing when returned callback is fired", async () => { 34 | const onEnter = () => () => {} 35 | const mockedOnEnter = jest.fn(onEnter) 36 | const element = document.createElement("div") 37 | const stop = inView(element, mockedOnEnter) 38 | 39 | expect(getActiveObserver()).toBeTruthy() 40 | getActiveObserver()?.([{ target: element, isIntersecting: true }]) 41 | 42 | expect(mockedOnEnter).toBeCalledTimes(1) 43 | 44 | stop() 45 | 46 | getActiveObserver()?.([{ target: element, isIntersecting: false }]) 47 | getActiveObserver()?.([{ target: element, isIntersecting: true }]) 48 | 49 | expect(mockedOnEnter).toBeCalledTimes(1) 50 | }) 51 | 52 | test("Only fires onEnter once if it returns void", async () => { 53 | const onEnter = jest.fn() 54 | const element = document.createElement("div") 55 | inView(element, onEnter) 56 | 57 | expect(getActiveObserver()).toBeTruthy() 58 | getActiveObserver()?.([{ target: element, isIntersecting: true }]) 59 | 60 | expect(onEnter).toBeCalledTimes(1) 61 | getActiveObserver()?.([{ target: element, isIntersecting: false }]) 62 | getActiveObserver()?.([{ target: element, isIntersecting: true }]) 63 | expect(onEnter).toBeCalledTimes(1) 64 | }) 65 | 66 | test("Only fires onEnter once if it returns something other than a function", async () => { 67 | const onEnter = () => 5 68 | const mockOnEnter = jest.fn(onEnter) 69 | const element = document.createElement("div") 70 | inView(element, mockOnEnter as any) 71 | 72 | expect(getActiveObserver()).toBeTruthy() 73 | getActiveObserver()?.([{ target: element, isIntersecting: true }]) 74 | 75 | expect(mockOnEnter).toBeCalledTimes(1) 76 | getActiveObserver()?.([{ target: element, isIntersecting: false }]) 77 | getActiveObserver()?.([{ target: element, isIntersecting: true }]) 78 | expect(mockOnEnter).toBeCalledTimes(1) 79 | }) 80 | }) 81 | -------------------------------------------------------------------------------- /packages/dom/src/gestures/__tests__/mock-intersection-observer.ts: -------------------------------------------------------------------------------- 1 | export type MockIntersectionObserverEntry = { 2 | isIntersecting: boolean 3 | target: Element 4 | } 5 | 6 | export type MockIntersectionObserverCallback = ( 7 | entries: MockIntersectionObserverEntry[] 8 | ) => void 9 | 10 | let activeIntersectionObserver: MockIntersectionObserverCallback | undefined 11 | 12 | export const getActiveObserver = () => activeIntersectionObserver 13 | 14 | window.IntersectionObserver = class MockIntersectionObserver { 15 | callback: MockIntersectionObserverCallback 16 | 17 | constructor(callback: MockIntersectionObserverCallback) { 18 | this.callback = callback 19 | } 20 | 21 | observe(_element: Element) { 22 | activeIntersectionObserver = this.callback 23 | } 24 | 25 | unobserve(_element: Element) { 26 | activeIntersectionObserver = undefined 27 | } 28 | 29 | disconnect() { 30 | activeIntersectionObserver = undefined 31 | } 32 | } as any 33 | -------------------------------------------------------------------------------- /packages/dom/src/gestures/in-view.ts: -------------------------------------------------------------------------------- 1 | import { ElementOrSelector } from "../types" 2 | import { resolveElements } from "../utils/resolve-elements" 3 | import { isFunction } from "@motionone/utils" 4 | 5 | export type ViewChangeHandler = (entry: IntersectionObserverEntry) => void 6 | 7 | export interface InViewOptions { 8 | root?: Element | Document 9 | margin?: string 10 | amount?: "any" | "all" | number 11 | } 12 | 13 | const thresholds = { 14 | any: 0, 15 | all: 1, 16 | } 17 | 18 | export function inView( 19 | elementOrSelector: ElementOrSelector, 20 | onStart: (entry: IntersectionObserverEntry) => void | ViewChangeHandler, 21 | { root, margin: rootMargin, amount = "any" }: InViewOptions = {} 22 | ): VoidFunction { 23 | /** 24 | * If this browser doesn't support IntersectionObserver, return a dummy stop function. 25 | * Default triggering of onStart is tricky - it could be used for starting/stopping 26 | * videos, lazy loading content etc. We could provide an option to enable a fallback, or 27 | * provide a fallback callback option. 28 | */ 29 | if (typeof IntersectionObserver === "undefined") { 30 | return () => {} 31 | } 32 | 33 | const elements = resolveElements(elementOrSelector) 34 | 35 | const activeIntersections = new WeakMap() 36 | 37 | const onIntersectionChange: IntersectionObserverCallback = (entries) => { 38 | entries.forEach((entry) => { 39 | const onEnd = activeIntersections.get(entry.target) 40 | 41 | /** 42 | * If there's no change to the intersection, we don't need to 43 | * do anything here. 44 | */ 45 | if (entry.isIntersecting === Boolean(onEnd)) return 46 | 47 | if (entry.isIntersecting) { 48 | const newOnEnd = onStart(entry) 49 | if (isFunction(newOnEnd)) { 50 | activeIntersections.set(entry.target, newOnEnd) 51 | } else { 52 | observer.unobserve(entry.target) 53 | } 54 | } else if (onEnd) { 55 | onEnd(entry) 56 | activeIntersections.delete(entry.target) 57 | } 58 | }) 59 | } 60 | 61 | const observer = new IntersectionObserver(onIntersectionChange, { 62 | root, 63 | rootMargin, 64 | threshold: typeof amount === "number" ? amount : thresholds[amount], 65 | }) 66 | 67 | elements.forEach((element) => observer.observe(element)) 68 | 69 | return () => observer.disconnect() 70 | } 71 | -------------------------------------------------------------------------------- /packages/dom/src/gestures/resize/__tests__/mock-resize-observer.ts: -------------------------------------------------------------------------------- 1 | export type MockResizeObserverEntry = { 2 | target: Element 3 | } 4 | 5 | export type MockResizeObserverCallback = ( 6 | entries: MockResizeObserverEntry[] 7 | ) => void 8 | 9 | let activeObserver: any 10 | 11 | window.ResizeObserver = class MockResizeObserver { 12 | elements = new Set() 13 | 14 | callback: MockResizeObserverCallback | undefined 15 | 16 | constructor(callback: MockResizeObserverCallback) { 17 | this.callback = callback 18 | activeObserver = this 19 | } 20 | 21 | observe(element: Element) { 22 | this.elements.add(element) 23 | } 24 | 25 | unobserve(element: Element) { 26 | this.elements.delete(element) 27 | } 28 | 29 | disconnect() { 30 | this.elements = new Set() 31 | this.callback = undefined 32 | activeObserver = undefined 33 | } 34 | 35 | notify() { 36 | const entries = Array.from(this.elements).map((element) => ({ 37 | target: element, 38 | contentRect: {}, 39 | borderBoxSize: {}, 40 | })) 41 | 42 | this.callback?.(entries) 43 | } 44 | } as any 45 | 46 | export function triggerResize() { 47 | activeObserver?.notify() 48 | } 49 | -------------------------------------------------------------------------------- /packages/dom/src/gestures/resize/handle-element.ts: -------------------------------------------------------------------------------- 1 | import { ElementOrSelector } from "../../types" 2 | import { resolveElements } from "../../utils/resolve-elements" 3 | import { ResizeHandler } from "./types" 4 | 5 | const resizeHandlers = new WeakMap>>() 6 | 7 | let observer: ResizeObserver | undefined 8 | 9 | function getElementSize( 10 | target: Element, 11 | borderBoxSize?: ReadonlyArray 12 | ) { 13 | if (borderBoxSize) { 14 | const { inlineSize, blockSize } = borderBoxSize[0] 15 | return { width: inlineSize, height: blockSize } 16 | } else if (target instanceof SVGElement && "getBBox" in target) { 17 | return (target as SVGGraphicsElement).getBBox() 18 | } else { 19 | return { 20 | width: (target as HTMLElement).offsetWidth, 21 | height: (target as HTMLElement).offsetHeight, 22 | } 23 | } 24 | } 25 | 26 | function notifyTarget({ 27 | target, 28 | contentRect, 29 | borderBoxSize, 30 | }: ResizeObserverEntry) { 31 | resizeHandlers.get(target)?.forEach((handler) => { 32 | handler({ 33 | target, 34 | contentSize: contentRect, 35 | get size() { 36 | return getElementSize(target, borderBoxSize) 37 | }, 38 | }) 39 | }) 40 | } 41 | 42 | function notifyAll(entries: ResizeObserverEntry[]) { 43 | entries.forEach(notifyTarget) 44 | } 45 | 46 | function createResizeObserver() { 47 | if (typeof ResizeObserver === "undefined") return 48 | 49 | observer = new ResizeObserver(notifyAll) 50 | } 51 | 52 | export function resizeElement( 53 | target: ElementOrSelector, 54 | handler: ResizeHandler 55 | ) { 56 | if (!observer) createResizeObserver() 57 | 58 | const elements = resolveElements(target) 59 | 60 | elements.forEach((element) => { 61 | let elementHandlers = resizeHandlers.get(element) 62 | 63 | if (!elementHandlers) { 64 | elementHandlers = new Set() 65 | resizeHandlers.set(element, elementHandlers) 66 | } 67 | 68 | elementHandlers.add(handler) 69 | observer?.observe(element) 70 | }) 71 | 72 | return () => { 73 | elements.forEach((element) => { 74 | const elementHandlers = resizeHandlers.get(element) 75 | 76 | elementHandlers?.delete(handler) 77 | 78 | if (!elementHandlers?.size) { 79 | observer?.unobserve(element) 80 | } 81 | }) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /packages/dom/src/gestures/resize/handle-window.ts: -------------------------------------------------------------------------------- 1 | import { ResizeHandler } from "./types" 2 | 3 | const windowCallbacks = new Set>() 4 | 5 | let windowResizeHandler: VoidFunction | undefined 6 | 7 | function createWindowResizeHandler() { 8 | windowResizeHandler = () => { 9 | const size = { 10 | width: window.innerWidth, 11 | height: window.innerHeight, 12 | } 13 | 14 | const info = { 15 | target: window, 16 | size, 17 | contentSize: size, 18 | } 19 | 20 | windowCallbacks.forEach((callback) => callback(info)) 21 | } 22 | 23 | window.addEventListener("resize", windowResizeHandler) 24 | } 25 | 26 | export function resizeWindow(callback: ResizeHandler) { 27 | windowCallbacks.add(callback) 28 | 29 | if (!windowResizeHandler) createWindowResizeHandler() 30 | 31 | return () => { 32 | windowCallbacks.delete(callback) 33 | 34 | if (!windowCallbacks.size && windowResizeHandler) { 35 | windowResizeHandler = undefined 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/dom/src/gestures/resize/index.ts: -------------------------------------------------------------------------------- 1 | import { ElementOrSelector } from "../../types" 2 | import { resizeElement } from "./handle-element" 3 | import { resizeWindow } from "./handle-window" 4 | import { ResizeHandler } from "./types" 5 | import { isFunction } from "@motionone/utils" 6 | 7 | export function resize(onResize: ResizeHandler): VoidFunction 8 | export function resize( 9 | target: ElementOrSelector, 10 | onResize: ResizeHandler 11 | ): VoidFunction 12 | export function resize( 13 | a: ResizeHandler | ElementOrSelector, 14 | b?: ResizeHandler 15 | ) { 16 | return isFunction(a) ? resizeWindow(a) : resizeElement(a, b!) 17 | } 18 | -------------------------------------------------------------------------------- /packages/dom/src/gestures/resize/types.ts: -------------------------------------------------------------------------------- 1 | export interface Size { 2 | width: number 3 | height: number 4 | } 5 | 6 | export interface ResizeInfo { 7 | target: I 8 | size: Size 9 | contentSize: Size 10 | } 11 | 12 | export type ResizeHandler = (info: ResizeInfo) => void 13 | -------------------------------------------------------------------------------- /packages/dom/src/gestures/scroll/index.ts: -------------------------------------------------------------------------------- 1 | import { AnimationControls } from "@motionone/types" 2 | import { resize } from "../resize/index" 3 | import { createScrollInfo } from "./info" 4 | import { createOnScrollHandler } from "./on-scroll-handler" 5 | import { OnScroll, OnScrollHandler, ScrollOptions } from "./types" 6 | 7 | const scrollListeners = new WeakMap() 8 | const resizeListeners = new WeakMap() 9 | const onScrollHandlers = new WeakMap>() 10 | 11 | export type ScrollTargets = Array 12 | 13 | const getEventTarget = (element: HTMLElement) => 14 | element === document.documentElement ? window : element 15 | 16 | export function scroll( 17 | controls: AnimationControls, 18 | options?: ScrollOptions 19 | ): VoidFunction 20 | export function scroll( 21 | onScroll: OnScroll, 22 | options?: ScrollOptions 23 | ): VoidFunction 24 | export function scroll( 25 | onScroll: OnScroll | AnimationControls, 26 | { container = document.documentElement, ...options }: ScrollOptions = {} 27 | ) { 28 | let containerHandlers = onScrollHandlers.get(container) 29 | 30 | /** 31 | * Get the onScroll handlers for this container. 32 | * If one isn't found, create a new one. 33 | */ 34 | if (!containerHandlers) { 35 | containerHandlers = new Set() 36 | onScrollHandlers.set(container, containerHandlers) 37 | } 38 | 39 | /** 40 | * Create a new onScroll handler for the provided callback. 41 | */ 42 | const info = createScrollInfo() 43 | const containerHandler = createOnScrollHandler( 44 | container, 45 | onScroll, 46 | info, 47 | options 48 | ) 49 | containerHandlers.add(containerHandler) 50 | 51 | /** 52 | * Check if there's a scroll event listener for this container. 53 | * If not, create one. 54 | */ 55 | if (!scrollListeners.has(container)) { 56 | const listener = () => { 57 | const time = performance.now() 58 | 59 | for (const handler of containerHandlers!) handler.measure() 60 | for (const handler of containerHandlers!) handler.update(time) 61 | for (const handler of containerHandlers!) handler.notify() 62 | } 63 | 64 | scrollListeners.set(container, listener) 65 | 66 | const target = getEventTarget(container) 67 | window.addEventListener("resize", listener, { passive: true }) 68 | if (container !== document.documentElement) { 69 | resizeListeners.set(container, resize(container, listener)) 70 | } 71 | target.addEventListener("scroll", listener, { passive: true }) 72 | } 73 | 74 | const listener = scrollListeners.get(container)! 75 | const onLoadProcesss = requestAnimationFrame(listener) 76 | 77 | return () => { 78 | if (typeof onScroll !== "function") onScroll.stop() 79 | 80 | cancelAnimationFrame(onLoadProcesss) 81 | 82 | /** 83 | * Check if we even have any handlers for this container. 84 | */ 85 | const containerHandlers = onScrollHandlers.get(container) 86 | if (!containerHandlers) return 87 | 88 | containerHandlers.delete(containerHandler) 89 | 90 | if (containerHandlers.size) return 91 | 92 | /** 93 | * If no more handlers, remove the scroll listener too. 94 | */ 95 | const listener = scrollListeners.get(container) 96 | scrollListeners.delete(container) 97 | 98 | if (listener) { 99 | getEventTarget(container).removeEventListener("scroll", listener) 100 | resizeListeners.get(container)?.() 101 | window.removeEventListener("resize", listener) 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /packages/dom/src/gestures/scroll/info.ts: -------------------------------------------------------------------------------- 1 | import { progress, velocityPerSecond } from "@motionone/utils" 2 | import { AxisScrollInfo, ScrollInfo } from "./types" 3 | 4 | /** 5 | * A time in milliseconds, beyond which we consider the scroll velocity to be 0. 6 | */ 7 | const maxElapsed = 50 8 | 9 | const createAxisInfo = (): AxisScrollInfo => ({ 10 | current: 0, 11 | offset: [], 12 | progress: 0, 13 | scrollLength: 0, 14 | targetOffset: 0, 15 | targetLength: 0, 16 | containerLength: 0, 17 | velocity: 0, 18 | }) 19 | 20 | export const createScrollInfo = (): ScrollInfo => ({ 21 | time: 0, 22 | x: createAxisInfo(), 23 | y: createAxisInfo(), 24 | }) 25 | 26 | const keys = { 27 | x: { 28 | length: "Width", 29 | position: "Left", 30 | }, 31 | y: { 32 | length: "Height", 33 | position: "Top", 34 | }, 35 | } as const 36 | 37 | function updateAxisInfo( 38 | element: HTMLElement, 39 | axisName: "x" | "y", 40 | info: ScrollInfo, 41 | time: number 42 | ) { 43 | const axis = info[axisName] 44 | const { length, position } = keys[axisName] 45 | 46 | const prev = axis.current 47 | const prevTime = info.time 48 | 49 | axis.current = element[`scroll${position}`] 50 | axis.scrollLength = element[`scroll${length}`] - element[`client${length}`] 51 | axis.offset.length = 0 52 | axis.offset[0] = 0 53 | axis.offset[1] = axis.scrollLength 54 | axis.progress = progress(0, axis.scrollLength, axis.current) 55 | 56 | const elapsed = time - prevTime 57 | axis.velocity = 58 | elapsed > maxElapsed ? 0 : velocityPerSecond(axis.current - prev, elapsed) 59 | } 60 | 61 | export function updateScrollInfo( 62 | element: HTMLElement, 63 | info: ScrollInfo, 64 | time: number 65 | ) { 66 | updateAxisInfo(element, "x", info, time) 67 | updateAxisInfo(element, "y", info, time) 68 | info.time = time 69 | } 70 | -------------------------------------------------------------------------------- /packages/dom/src/gestures/scroll/offsets/__tests__/edge.test.ts: -------------------------------------------------------------------------------- 1 | import { resolveEdge } from "../edge" 2 | 3 | describe("resolveEdge", () => { 4 | test("It handles progress numbers", () => { 5 | expect(resolveEdge(0, 300)).toEqual(0) 6 | expect(resolveEdge(0, 300, 200)).toEqual(200) 7 | expect(resolveEdge(0.5, 300)).toEqual(150) 8 | expect(resolveEdge(0.5, 300, 200)).toEqual(350) 9 | expect(resolveEdge(1, 300)).toEqual(300) 10 | expect(resolveEdge(1, 300, 200)).toEqual(500) 11 | }) 12 | 13 | test("It handles progress numbers as string", () => { 14 | expect(resolveEdge("0", 300)).toEqual(0) 15 | expect(resolveEdge("0", 300, 200)).toEqual(200) 16 | expect(resolveEdge("0.5", 300)).toEqual(150) 17 | expect(resolveEdge("0.5", 300, 200)).toEqual(350) 18 | expect(resolveEdge("1", 300)).toEqual(300) 19 | expect(resolveEdge("1", 300, 200)).toEqual(500) 20 | }) 21 | 22 | test("It handles named presets", () => { 23 | expect(resolveEdge("start", 300)).toEqual(0) 24 | expect(resolveEdge("start", 300, 200)).toEqual(200) 25 | expect(resolveEdge("center", 300)).toEqual(150) 26 | expect(resolveEdge("center", 300, 200)).toEqual(350) 27 | expect(resolveEdge("end", 300)).toEqual(300) 28 | expect(resolveEdge("end", 300, 200)).toEqual(500) 29 | }) 30 | 31 | test("It handles pixels", () => { 32 | expect(resolveEdge("0px", 300)).toEqual(0) 33 | expect(resolveEdge("0px", 300, 200)).toEqual(200) 34 | expect(resolveEdge("150px", 300)).toEqual(150) 35 | expect(resolveEdge("150px", 300, 200)).toEqual(350) 36 | expect(resolveEdge("300px", 300)).toEqual(300) 37 | expect(resolveEdge("300px", 300, 200)).toEqual(500) 38 | }) 39 | 40 | test("It handles percent", () => { 41 | expect(resolveEdge("0%", 300)).toEqual(0) 42 | expect(resolveEdge("0%", 300, 200)).toEqual(200) 43 | expect(resolveEdge("50%", 300)).toEqual(150) 44 | expect(resolveEdge("50%", 300, 200)).toEqual(350) 45 | expect(resolveEdge("100%", 300)).toEqual(300) 46 | expect(resolveEdge("100%", 300, 200)).toEqual(500) 47 | }) 48 | 49 | test("It handles vw", () => { 50 | Object.defineProperty(document.documentElement, "clientWidth", { 51 | value: 1000, 52 | }) 53 | 54 | expect(resolveEdge("0vw", 300)).toEqual(0) 55 | expect(resolveEdge("0vw", 300, 200)).toEqual(200) 56 | expect(resolveEdge("50vw", 300)).toEqual(500) 57 | expect(resolveEdge("50vw", 300, 200)).toEqual(700) 58 | expect(resolveEdge("100vw", 300)).toEqual(1000) 59 | expect(resolveEdge("100vw", 300, 200)).toEqual(1200) 60 | }) 61 | 62 | test("It handles vh", () => { 63 | Object.defineProperty(document.documentElement, "clientHeight", { 64 | value: 1000, 65 | }) 66 | 67 | expect(resolveEdge("0vh", 300)).toEqual(0) 68 | expect(resolveEdge("0vh", 300, 200)).toEqual(200) 69 | expect(resolveEdge("50vh", 300)).toEqual(500) 70 | expect(resolveEdge("50vh", 300, 200)).toEqual(700) 71 | expect(resolveEdge("100vh", 300)).toEqual(1000) 72 | expect(resolveEdge("100vh", 300, 200)).toEqual(1200) 73 | }) 74 | }) 75 | -------------------------------------------------------------------------------- /packages/dom/src/gestures/scroll/offsets/__tests__/offset.test.ts: -------------------------------------------------------------------------------- 1 | import { resolveOffset } from "../offset" 2 | 3 | describe("resolveOffset", () => { 4 | test("Can resolve single-value numerical edge", () => { 5 | expect(resolveOffset(0, 100, 500, 0)).toEqual(0) 6 | expect(resolveOffset(0.5, 100, 500, 0)).toEqual(200) 7 | expect(resolveOffset(1, 100, 500, 0)).toEqual(400) 8 | expect(resolveOffset(0, 100, 500, 200)).toEqual(200) 9 | expect(resolveOffset(0.5, 100, 500, 200)).toEqual(400) 10 | expect(resolveOffset(1, 100, 500, 200)).toEqual(600) 11 | }) 12 | 13 | test("Can resolve single-value labelled edge", () => { 14 | expect(resolveOffset("start", 100, 500, 0)).toEqual(0) 15 | expect(resolveOffset("center", 100, 500, 0)).toEqual(200) 16 | expect(resolveOffset("end", 100, 500, 0)).toEqual(400) 17 | expect(resolveOffset("start", 100, 500, 200)).toEqual(200) 18 | expect(resolveOffset("center", 100, 500, 200)).toEqual(400) 19 | expect(resolveOffset("end", 100, 500, 200)).toEqual(600) 20 | }) 21 | 22 | test("Can resolve single-value px", () => { 23 | expect(resolveOffset("100px", 100, 500, 0)).toEqual(100) 24 | expect(resolveOffset("500px", 100, 500, 0)).toEqual(500) 25 | expect(resolveOffset("100px", 100, 500, 200)).toEqual(300) 26 | expect(resolveOffset("500px", 100, 500, 200)).toEqual(700) 27 | }) 28 | 29 | test("Can resolve string intersection", () => { 30 | expect(resolveOffset("center start", 100, 50, 0)).toEqual(25) 31 | expect(resolveOffset("start center", 100, 200, 0)).toEqual(-50) 32 | expect(resolveOffset("start end", 100, 200, 0)).toEqual(-100) 33 | expect(resolveOffset("0.5 0", 100, 50, 0)).toEqual(25) 34 | expect(resolveOffset("0 0.5", 100, 200, 0)).toEqual(-50) 35 | expect(resolveOffset("0 1", 100, 200, 0)).toEqual(-100) 36 | }) 37 | 38 | test("Can resolve numerical intersection", () => { 39 | expect(resolveOffset([0, 0], 100, 50, 0)).toEqual(0) 40 | expect(resolveOffset([0, 0], 100, 50, 200)).toEqual(200) 41 | expect(resolveOffset([0, 1], 100, 50, 0)).toEqual(-100) 42 | expect(resolveOffset([0, 1], 100, 200, 0)).toEqual(-100) 43 | expect(resolveOffset([0, 1], 100, 50, 200)).toEqual(100) 44 | expect(resolveOffset([0, 1], 100, 200, 200)).toEqual(100) 45 | expect(resolveOffset([1, 1], 100, 50, 0)).toEqual(-50) 46 | expect(resolveOffset([1, 1], 100, 200, 0)).toEqual(100) 47 | expect(resolveOffset([1, 1], 100, 50, 200)).toEqual(150) 48 | expect(resolveOffset([1, 0], 100, 50, 0)).toEqual(50) 49 | expect(resolveOffset([1, 0], 100, 200, 0)).toEqual(200) 50 | expect(resolveOffset([1, 0], 100, 50, 200)).toEqual(250) 51 | expect(resolveOffset([1, 0], 100, 200, 200)).toEqual(400) 52 | }) 53 | }) 54 | -------------------------------------------------------------------------------- /packages/dom/src/gestures/scroll/offsets/edge.ts: -------------------------------------------------------------------------------- 1 | import { isNumber, isString } from "@motionone/utils" 2 | import { Edge, NamedEdges } from "../types" 3 | 4 | export const namedEdges: Record = { 5 | start: 0, 6 | center: 0.5, 7 | end: 1, 8 | } 9 | 10 | export function resolveEdge(edge: Edge, length: number, inset = 0) { 11 | let delta = 0 12 | 13 | /** 14 | * If we have this edge defined as a preset, replace the definition 15 | * with the numerical value. 16 | */ 17 | if (namedEdges[edge as keyof typeof namedEdges] !== undefined) { 18 | edge = namedEdges[edge as keyof typeof namedEdges] 19 | } 20 | 21 | /** 22 | * Handle unit values 23 | */ 24 | if (isString(edge)) { 25 | const asNumber = parseFloat(edge) 26 | 27 | if (edge.endsWith("px")) { 28 | delta = asNumber 29 | } else if (edge.endsWith("%")) { 30 | edge = asNumber / 100 31 | } else if (edge.endsWith("vw")) { 32 | delta = (asNumber / 100) * document.documentElement.clientWidth 33 | } else if (edge.endsWith("vh")) { 34 | delta = (asNumber / 100) * document.documentElement.clientHeight 35 | } else { 36 | edge = asNumber 37 | } 38 | } 39 | 40 | /** 41 | * If the edge is defined as a number, handle as a progress value. 42 | */ 43 | if (isNumber(edge)) { 44 | delta = length * edge 45 | } 46 | 47 | return inset + delta 48 | } 49 | -------------------------------------------------------------------------------- /packages/dom/src/gestures/scroll/offsets/index.ts: -------------------------------------------------------------------------------- 1 | import { defaultOffset, interpolate } from "@motionone/utils" 2 | import { ScrollInfo } from "../types" 3 | import { calcInset } from "./inset" 4 | import { ScrollOffset } from "./presets" 5 | import { ScrollOptions } from "../types" 6 | import { resolveOffset } from "./offset" 7 | 8 | const point = { x: 0, y: 0 } 9 | 10 | export function resolveOffsets( 11 | container: HTMLElement, 12 | info: ScrollInfo, 13 | options: ScrollOptions 14 | ) { 15 | let { offset: offsetDefinition = ScrollOffset.All } = options 16 | const { target = container, axis = "y" } = options 17 | const lengthLabel = axis === "y" ? "height" : "width" 18 | 19 | const inset = target !== container ? calcInset(target, container) : point 20 | 21 | /** 22 | * Measure the target and container. If they're the same thing then we 23 | * use the container's scrollWidth/Height as the target, from there 24 | * all other calculations can remain the same. 25 | */ 26 | const targetSize = 27 | target === container 28 | ? { width: container.scrollWidth, height: container.scrollHeight } 29 | : { width: target.clientWidth, height: target.clientHeight } 30 | 31 | const containerSize = { 32 | width: container.clientWidth, 33 | height: container.clientHeight, 34 | } 35 | 36 | /** 37 | * Reset the length of the resolved offset array rather than creating a new one. 38 | * TODO: More reusable data structures for targetSize/containerSize would also be good. 39 | */ 40 | info[axis].offset.length = 0 41 | 42 | /** 43 | * Populate the offset array by resolving the user's offset definition into 44 | * a list of pixel scroll offets. 45 | */ 46 | let hasChanged = !info[axis].interpolate 47 | 48 | const numOffsets = offsetDefinition.length 49 | for (let i = 0; i < numOffsets; i++) { 50 | const offset = resolveOffset( 51 | offsetDefinition[i], 52 | containerSize[lengthLabel], 53 | targetSize[lengthLabel], 54 | inset[axis] 55 | ) 56 | 57 | if (!hasChanged && offset !== info[axis].interpolatorOffsets![i]) { 58 | hasChanged = true 59 | } 60 | 61 | info[axis].offset[i] = offset 62 | } 63 | 64 | /** 65 | * If the pixel scroll offsets have changed, create a new interpolator function 66 | * to map scroll value into a progress. 67 | */ 68 | if (hasChanged) { 69 | info[axis].interpolate = interpolate( 70 | defaultOffset(numOffsets), 71 | info[axis].offset 72 | ) 73 | 74 | info[axis].interpolatorOffsets = [...info[axis].offset] 75 | } 76 | info[axis].progress = info[axis].interpolate!(info[axis].current) 77 | } 78 | -------------------------------------------------------------------------------- /packages/dom/src/gestures/scroll/offsets/inset.ts: -------------------------------------------------------------------------------- 1 | export function calcInset(element: Element, container: HTMLElement) { 2 | let inset = { x: 0, y: 0 } 3 | 4 | let current: Element | null = element 5 | while (current && current !== container) { 6 | if (current instanceof HTMLElement) { 7 | inset.x += current.offsetLeft 8 | inset.y += current.offsetTop 9 | current = current.offsetParent 10 | } else if (current instanceof SVGGraphicsElement && "getBBox" in current) { 11 | const { top, left } = current.getBBox() 12 | inset.x += left 13 | inset.y += top 14 | 15 | /** 16 | * Assign the next parent element as the tag. 17 | */ 18 | while (current && current.tagName !== "svg") { 19 | current = current.parentNode as SVGElement 20 | } 21 | } 22 | } 23 | 24 | return inset 25 | } 26 | -------------------------------------------------------------------------------- /packages/dom/src/gestures/scroll/offsets/offset.ts: -------------------------------------------------------------------------------- 1 | import { isNumber, isString } from "@motionone/utils" 2 | import { Edge, EdgeString, Intersection, ProgressIntersection } from "../types" 3 | import { namedEdges, resolveEdge } from "./edge" 4 | 5 | const defaultOffset: ProgressIntersection = [0, 0] 6 | 7 | export function resolveOffset( 8 | offset: Edge | Intersection | ProgressIntersection, 9 | containerLength: number, 10 | targetLength: number, 11 | targetInset: number 12 | ) { 13 | let offsetDefinition: ProgressIntersection | [EdgeString, EdgeString] = 14 | Array.isArray(offset) ? offset : defaultOffset 15 | 16 | let targetPoint = 0 17 | let containerPoint = 0 18 | 19 | if (isNumber(offset)) { 20 | /** 21 | * If we're provided offset: [0, 0.5, 1] then each number x should become 22 | * [x, x], so we default to the behaviour of mapping 0 => 0 of both target 23 | * and container etc. 24 | */ 25 | offsetDefinition = [offset, offset] 26 | } else if (isString(offset)) { 27 | offset = offset.trim() as EdgeString 28 | 29 | if (offset.includes(" ")) { 30 | offsetDefinition = offset.split(" ") as [EdgeString, EdgeString] 31 | } else { 32 | /** 33 | * If we're provided a definition like "100px" then we want to apply 34 | * that only to the top of the target point, leaving the container at 0. 35 | * Whereas a named offset like "end" should be applied to both. 36 | */ 37 | offsetDefinition = [ 38 | offset, 39 | namedEdges[offset as keyof typeof namedEdges] ? offset : `0`, 40 | ] 41 | } 42 | } 43 | 44 | targetPoint = resolveEdge(offsetDefinition[0], targetLength, targetInset) 45 | containerPoint = resolveEdge(offsetDefinition[1], containerLength) 46 | 47 | return targetPoint - containerPoint 48 | } 49 | -------------------------------------------------------------------------------- /packages/dom/src/gestures/scroll/offsets/presets.ts: -------------------------------------------------------------------------------- 1 | import { ProgressIntersection } from "../types" 2 | 3 | export const ScrollOffset: Record = { 4 | Enter: [ 5 | [0, 1], 6 | [1, 1], 7 | ], 8 | Exit: [ 9 | [0, 0], 10 | [1, 0], 11 | ], 12 | Any: [ 13 | [1, 0], 14 | [0, 1], 15 | ], 16 | All: [ 17 | [0, 0], 18 | [1, 1], 19 | ], 20 | } 21 | -------------------------------------------------------------------------------- /packages/dom/src/gestures/scroll/on-scroll-handler.ts: -------------------------------------------------------------------------------- 1 | import { AnimationControls } from "@motionone/types" 2 | import { noopReturn, isFunction } from "@motionone/utils" 3 | import { updateScrollInfo } from "./info" 4 | import { resolveOffsets } from "./offsets/index" 5 | import { 6 | AxisScrollInfo, 7 | OnScroll, 8 | OnScrollHandler, 9 | ScrollInfo, 10 | ScrollOptions, 11 | } from "./types" 12 | 13 | function measure( 14 | container: HTMLElement, 15 | target: Element = container, 16 | info: ScrollInfo 17 | ) { 18 | /** 19 | * Find inset of target within scrollable container 20 | */ 21 | info.x.targetOffset = 0 22 | info.y.targetOffset = 0 23 | if (target !== container) { 24 | let node = target as HTMLElement 25 | while (node && node != container) { 26 | info.x.targetOffset += node.offsetLeft 27 | info.y.targetOffset += node.offsetTop 28 | node = node.offsetParent as HTMLElement 29 | } 30 | } 31 | 32 | info.x.targetLength = 33 | target === container ? target.scrollWidth : target.clientWidth 34 | info.y.targetLength = 35 | target === container ? target.scrollHeight : target.clientHeight 36 | info.x.containerLength = container.clientWidth 37 | info.y.containerLength = container.clientHeight 38 | } 39 | 40 | export function createOnScrollHandler( 41 | element: HTMLElement, 42 | onScroll: OnScroll | AnimationControls, 43 | info: ScrollInfo, 44 | options: ScrollOptions = {} 45 | ): OnScrollHandler { 46 | const axis = options.axis || "y" 47 | return { 48 | measure: () => measure(element, options.target, info), 49 | update: (time) => { 50 | updateScrollInfo(element, info, time) 51 | 52 | if (options.offset || options.target) { 53 | resolveOffsets(element, info, options) 54 | } 55 | }, 56 | notify: isFunction(onScroll) 57 | ? () => onScroll(info) 58 | : scrubAnimation(onScroll, info[axis]), 59 | } 60 | } 61 | 62 | function scrubAnimation(controls: AnimationControls, axisInfo: AxisScrollInfo) { 63 | controls.pause() 64 | 65 | /** 66 | * Normalize the animations to linear/1s to make them more 67 | * predictable to scrub through. 68 | * 69 | * TODO: Fix casting here 70 | */ 71 | ;(controls as any).forEachNative((animation: any, { easing }: any) => { 72 | if (animation.updateDuration) { 73 | if (!easing) animation.easing = noopReturn 74 | animation.updateDuration(1) 75 | } else { 76 | const timingOptions: OptionalEffectTiming = { duration: 1000 } 77 | if (!easing) timingOptions.easing = "linear" 78 | 79 | animation.effect?.updateTiming?.(timingOptions) 80 | } 81 | }) 82 | 83 | return () => { 84 | controls.currentTime = axisInfo.progress 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /packages/dom/src/gestures/scroll/types.ts: -------------------------------------------------------------------------------- 1 | import { EasingFunction } from "@motionone/types" 2 | 3 | export interface AxisScrollInfo { 4 | current: number 5 | offset: number[] 6 | progress: number 7 | scrollLength: number 8 | velocity: number 9 | 10 | // TODO Rename before documenting 11 | targetOffset: number 12 | 13 | targetLength: number 14 | containerLength: number 15 | interpolatorOffsets?: number[] 16 | interpolate?: EasingFunction 17 | } 18 | 19 | export interface ScrollInfo { 20 | time: number 21 | x: AxisScrollInfo 22 | y: AxisScrollInfo 23 | } 24 | 25 | export type OnScroll = (info: ScrollInfo) => void 26 | 27 | export type OnScrollHandler = { 28 | measure: () => void 29 | update: (time: number) => void 30 | notify: () => void 31 | } 32 | 33 | export type SupportedEdgeUnit = "px" | "vw" | "vh" | "%" 34 | 35 | export type EdgeUnit = `${number}${SupportedEdgeUnit}` 36 | 37 | export type NamedEdges = "start" | "end" | "center" 38 | 39 | export type EdgeString = NamedEdges | EdgeUnit | `${number}` 40 | 41 | export type Edge = EdgeString | number 42 | 43 | export type ProgressIntersection = [number, number] 44 | 45 | export type Intersection = `${Edge} ${Edge}` 46 | 47 | export type ScrollOffset = Array 48 | 49 | export interface ScrollOptions { 50 | container?: HTMLElement 51 | target?: Element 52 | axis?: "x" | "y" 53 | offset?: ScrollOffset 54 | smooth?: number 55 | } 56 | -------------------------------------------------------------------------------- /packages/dom/src/index.ts: -------------------------------------------------------------------------------- 1 | export { animate } from "./animate/index" 2 | export { createAnimate } from "./animate/create-animate" 3 | export { animateStyle } from "./animate/animate-style" 4 | export { timeline } from "./timeline/index" 5 | export type { TimelineOptions } from "./timeline/index" 6 | export { stagger } from "./utils/stagger" 7 | export type { StaggerOptions } from "./utils/stagger" 8 | export { spring } from "./easing/spring/index" 9 | export type { SpringOptions } from "./easing/spring/index" 10 | export { glide } from "./easing/glide/index" 11 | export type { GlideOptions } from "./easing/glide/index" 12 | export { style } from "./animate/style" 13 | export * from "./gestures/in-view" 14 | export * from "./gestures/resize/index" 15 | export * from "./gestures/scroll/index" 16 | export * from "./gestures/scroll/types" 17 | export { ScrollOffset } from "./gestures/scroll/offsets/presets" 18 | 19 | export { withControls } from "./animate/utils/controls" 20 | export { getAnimationData } from "./animate/data" 21 | export { getStyleName } from "./animate/utils/get-style-name" 22 | export { createMotionState, mountedStates } from "./state/index" 23 | export { createStyles } from "./animate/utils/style-object" 24 | export { createStyleString } from "./animate/utils/style-string" 25 | 26 | export * from "./types" 27 | export * from "./state/types" 28 | export * from "./animate/types" 29 | export * from "./timeline/types" -------------------------------------------------------------------------------- /packages/dom/src/state/__tests__/hover.test.ts: -------------------------------------------------------------------------------- 1 | import { pointerEnter, pointerLeave } from "config/jest.setup" 2 | import { createTestMotionState } from "./utils" 3 | import "config/waapi-polyfill" 4 | 5 | describe("hover", () => { 6 | test("Animate to hover when hover starts", async () => { 7 | const { element } = createTestMotionState({ 8 | hover: { opacity: 0.5 }, 9 | transition: { duration: 0.01 }, 10 | }) 11 | 12 | await new Promise((resolve) => { 13 | element.addEventListener("motioncomplete", resolve) 14 | pointerEnter(element) 15 | }) 16 | 17 | expect(element).toHaveStyle("opacity: 0.5") 18 | }) 19 | 20 | test("Hover doesn't accept touch events", async () => { 21 | const { element } = createTestMotionState({ 22 | hover: { opacity: 0.5 }, 23 | transition: { duration: 0.01 }, 24 | }) 25 | 26 | const promise = new Promise((resolve, reject) => { 27 | element.addEventListener("motioncomplete", resolve) 28 | pointerEnter(element, "touch") 29 | setTimeout(() => reject(false), 50) 30 | }) 31 | 32 | expect(promise).rejects.toEqual(false) 33 | }) 34 | 35 | test("Animate to new hover pose while hover is active", async () => { 36 | const { element, state } = createTestMotionState({ 37 | hover: { opacity: 0.5 }, 38 | transition: { duration: 0.01 }, 39 | }) 40 | 41 | await new Promise((resolve) => { 42 | pointerEnter(element) 43 | 44 | requestAnimationFrame(() => { 45 | state.update({ 46 | hover: { opacity: 0.75 }, 47 | transition: { duration: 0.01 }, 48 | }) 49 | element.addEventListener("motioncomplete", resolve) 50 | }) 51 | }) 52 | 53 | expect(element).toHaveStyle("opacity: 0.75") 54 | }) 55 | 56 | test("Hover fires hoverstart event when hover is triggered", async () => { 57 | const { element } = createTestMotionState({ 58 | hover: { opacity: 1 }, 59 | transition: { duration: 0.01 }, 60 | }) 61 | 62 | await new Promise((resolve) => { 63 | element.addEventListener("hoverstart", resolve) 64 | pointerEnter(element) 65 | }) 66 | }) 67 | 68 | test("Hover fires hoverend event when hover is triggered", async () => { 69 | const { element } = createTestMotionState({ 70 | hover: { opacity: 1 }, 71 | transition: { duration: 0.01 }, 72 | }) 73 | 74 | await new Promise((resolve) => { 75 | element.addEventListener("hoverend", resolve) 76 | pointerEnter(element) 77 | pointerLeave(element) 78 | }) 79 | }) 80 | 81 | test("Animate from hover when hover ends", async () => { 82 | const { element } = createTestMotionState({ 83 | hover: { opacity: 0.5 }, 84 | transition: { duration: 0.01 }, 85 | }) 86 | element.style.opacity = "1" 87 | 88 | await new Promise((resolve) => { 89 | element.addEventListener("motioncomplete", ({ detail }: any) => { 90 | if (detail.target.opacity === "1") resolve() 91 | }) 92 | 93 | pointerEnter(element) 94 | setTimeout(() => { 95 | pointerLeave(element) 96 | }, 50) 97 | }) 98 | 99 | expect(element).toHaveStyle("opacity: 1") 100 | }) 101 | }) 102 | -------------------------------------------------------------------------------- /packages/dom/src/state/__tests__/in-view.test.ts: -------------------------------------------------------------------------------- 1 | import { createTestMotionState } from "./utils" 2 | import "config/waapi-polyfill" 3 | import { getActiveObserver } from "../../gestures/__tests__/mock-intersection-observer" 4 | 5 | describe("inView", () => { 6 | test("Animate to inView when element enters viewport", async () => { 7 | const { element } = createTestMotionState({ 8 | inView: { backgroundColor: "red" }, 9 | transition: { duration: 0.01 }, 10 | }) 11 | 12 | element.style.backgroundColor = "blue" 13 | 14 | expect(element).toHaveStyle("background-color: blue") 15 | 16 | await new Promise((resolve) => { 17 | expect(getActiveObserver()).toBeTruthy() 18 | 19 | getActiveObserver()?.([{ target: element, isIntersecting: true }]) 20 | 21 | setTimeout(resolve, 50) 22 | }) 23 | 24 | expect(element).toHaveStyle("background-color: red") 25 | }) 26 | 27 | test("Element receives viewenter event when inView is enabled", async () => { 28 | const { element } = createTestMotionState({ 29 | inView: { backgroundColor: "red" }, 30 | transition: { duration: 0.01 }, 31 | }) 32 | const receivedEvent = await new Promise((resolve) => { 33 | element.addEventListener("viewenter", () => resolve(true)) 34 | getActiveObserver()?.([{ target: element, isIntersecting: true }]) 35 | }) 36 | 37 | expect(receivedEvent).toEqual(true) 38 | }) 39 | 40 | test("Element receives viewleave event when inView is disabled", async () => { 41 | const { element } = createTestMotionState({ 42 | inView: { backgroundColor: "red" }, 43 | transition: { duration: 0.01 }, 44 | }) 45 | 46 | const receivedEvent = await new Promise((resolve) => { 47 | element.addEventListener("viewleave", () => resolve(true)) 48 | getActiveObserver()?.([{ target: element, isIntersecting: true }]) 49 | 50 | setTimeout(() => { 51 | getActiveObserver()?.([{ target: element, isIntersecting: false }]) 52 | }, 20) 53 | }) 54 | 55 | expect(receivedEvent).toEqual(true) 56 | }) 57 | 58 | test("Animate from inView when element enters viewport", async () => { 59 | const { element } = createTestMotionState({ 60 | inView: { backgroundColor: "red" }, 61 | transition: { duration: 0.001 }, 62 | }) 63 | element.style.backgroundColor = "blue" 64 | 65 | await new Promise((resolve) => { 66 | element.addEventListener("motioncomplete", ({ detail }) => { 67 | if (detail.target.backgroundColor === "red") { 68 | getActiveObserver()?.([{ target: element, isIntersecting: false }]) 69 | } else if (detail.target.backgroundColor === "blue") { 70 | resolve() 71 | } 72 | }) 73 | 74 | expect(getActiveObserver()).toBeTruthy() 75 | 76 | getActiveObserver()?.([{ target: element, isIntersecting: true }]) 77 | }) 78 | 79 | expect(element).toHaveStyle("background-color: blue") 80 | }) 81 | }) 82 | -------------------------------------------------------------------------------- /packages/dom/src/state/__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | import "config/waapi-polyfill" 2 | import { style } from "../../animate/style" 3 | import { createTestMotionState } from "./utils" 4 | 5 | describe("createMotionState()", () => { 6 | test("Types are correct", () => { 7 | createTestMotionState({ 8 | hover: "enlarge", 9 | press: { scale: 2 }, 10 | }) 11 | }) 12 | 13 | test("Style returns `initial` on mount", async () => { 14 | const { state } = createTestMotionState({ 15 | initial: { scale: 2 }, 16 | }) 17 | 18 | expect(state.getTarget()).toEqual({ 19 | scale: 2, 20 | }) 21 | }) 22 | 23 | test("If `initial` is false, style returns `animate` on mount", async () => { 24 | const { state } = createTestMotionState({ 25 | initial: false, 26 | animate: { scale: 2 }, 27 | }) 28 | 29 | expect(state.getTarget()).toEqual({ 30 | scale: 2, 31 | }) 32 | }) 33 | 34 | test("If animate is different to initial, fire animation.", async () => { 35 | const { element } = createTestMotionState({ 36 | initial: { scale: 2 }, 37 | animate: { scale: 1.5 }, 38 | transition: { duration: 0.01 }, 39 | }) 40 | 41 | expect(style.get(element, "scale")).toBe("2") 42 | expect(element).toHaveStyle("transform: scale(var(--motion-scale))") 43 | 44 | const motionStartHandler = jest.fn() 45 | const motionCompleteHandler = jest.fn() 46 | 47 | await new Promise((resolve) => { 48 | element.addEventListener("motionstart", ({ detail }) => 49 | motionStartHandler(detail.target) 50 | ) 51 | element.addEventListener("motioncomplete", ({ detail }) => { 52 | motionCompleteHandler(detail.target) 53 | resolve() 54 | }) 55 | }) 56 | 57 | expect(motionStartHandler).toBeCalledWith({ scale: 1.5 }) 58 | expect(motionCompleteHandler).toBeCalled() 59 | expect(style.get(element, "scale")).toBe("1.5") 60 | expect(element).toHaveStyle("transform: scale(var(--motion-scale))") 61 | }) 62 | 63 | test("If animate is different to previous animate, fire animation.", async () => { 64 | const { element, state } = createTestMotionState({ 65 | initial: { scale: 2 }, 66 | animate: { scale: 1.5 }, 67 | transition: { duration: 0.01 }, 68 | }) 69 | 70 | expect(style.get(element, "scale")).toBe("2") 71 | expect(element).toHaveStyle("transform: scale(var(--motion-scale))") 72 | 73 | let resolver = () => {} 74 | await new Promise((resolve) => { 75 | resolver = () => resolve() 76 | element.addEventListener("motioncomplete", resolver) 77 | }) 78 | 79 | element.removeEventListener("motioncomplete", resolver) 80 | 81 | expect(style.get(element, "scale")).toBe("1.5") 82 | expect(element).toHaveStyle("transform: scale(var(--motion-scale))") 83 | 84 | await new Promise((resolve) => { 85 | resolver = () => resolve() 86 | element.addEventListener("motioncomplete", resolver) 87 | state.update({ 88 | animate: { scale: 3, x: 100 }, 89 | transition: { duration: 0.01 }, 90 | }) 91 | }) 92 | 93 | expect(style.get(element, "scale")).toBe("3") 94 | expect(style.get(element, "x")).toBe("100px") 95 | expect(element).toHaveStyle( 96 | "transform: translateX(var(--motion-translateX)) scale(var(--motion-scale))" 97 | ) 98 | }) 99 | 100 | test("If animate is same as initial, fire animation.", async () => { 101 | const { element } = createTestMotionState({ 102 | initial: { scale: 2 }, 103 | animate: { scale: 2 }, 104 | transition: { duration: 0.01 }, 105 | }) 106 | 107 | expect(style.get(element, "scale")).toBe("2") 108 | expect(element).toHaveStyle("transform: scale(var(--motion-scale))") 109 | 110 | const motionStartHandler = jest.fn() 111 | const motionCompleteHandler = jest.fn() 112 | 113 | await new Promise((resolve) => { 114 | element.addEventListener("motionstart", motionStartHandler) 115 | element.addEventListener("motioncomplete", motionCompleteHandler) 116 | setTimeout(() => resolve(), 200) 117 | }) 118 | 119 | expect(motionStartHandler).not.toBeCalled() 120 | expect(motionCompleteHandler).not.toBeCalled() 121 | expect(style.get(element, "scale")).toBe("2") 122 | expect(element).toHaveStyle("transform: scale(var(--motion-scale))") 123 | }) 124 | 125 | test("State type can override default transition", async () => { 126 | const { element } = createTestMotionState({ 127 | initial: { opacity: 0 }, 128 | animate: { opacity: 0.5, transition: { duration: 0.1 } }, 129 | transition: { duration: 10 }, 130 | }) 131 | 132 | expect(style.get(element, "opacity")).toBe("0") 133 | 134 | await new Promise((resolve, reject) => { 135 | element.addEventListener("motioncomplete", () => resolve()) 136 | setTimeout(() => reject(), 1000) 137 | }) 138 | 139 | expect(style.get(element, "opacity")).toBe("0.5") 140 | }) 141 | 142 | test("If value is removed, animate to base", async () => { 143 | const { element, state } = createTestMotionState({ 144 | transition: { duration: 0.01 }, 145 | }) 146 | 147 | element.style.opacity = "0.5" 148 | 149 | let resolver = () => {} 150 | await new Promise((resolve) => { 151 | state.update({ 152 | animate: { opacity: 0.9 }, 153 | transition: { duration: 0.01 }, 154 | }) 155 | resolver = () => resolve() 156 | element.addEventListener("motioncomplete", resolver) 157 | }) 158 | 159 | element.removeEventListener("motioncomplete", resolver) 160 | 161 | expect(style.get(element, "opacity")).toBe("0.9") 162 | 163 | await new Promise((resolve) => { 164 | state.update({ 165 | transition: { duration: 0.01 }, 166 | }) 167 | resolver = () => resolve() 168 | element.addEventListener("motioncomplete", resolver) 169 | }) 170 | 171 | expect(style.get(element, "opacity")).toBe("0.5") 172 | }) 173 | }) 174 | -------------------------------------------------------------------------------- /packages/dom/src/state/__tests__/press.test.ts: -------------------------------------------------------------------------------- 1 | import { fireEvent } from "@testing-library/dom" 2 | import { pointerDown, pointerEnter, pointerLeave } from "config/jest.setup" 3 | import "config/waapi-polyfill" 4 | import { createTestMotionState } from "./utils" 5 | 6 | /** 7 | * TODO: 8 | * - Press only on single finger 9 | */ 10 | 11 | describe("press", () => { 12 | test("Animate to press when press starts", async () => { 13 | const { element } = createTestMotionState({ 14 | press: { scale: 0.5 }, 15 | transition: { duration: 0.01 }, 16 | }) 17 | 18 | await new Promise((resolve) => { 19 | element.addEventListener("motioncomplete", resolve) 20 | 21 | pointerDown(element) 22 | }) 23 | 24 | expect(element).toHaveStyle("--motion-scale: 0.5") 25 | }) 26 | 27 | test("Press fires pressstart event when press is triggered", async () => { 28 | const { element } = createTestMotionState({ 29 | press: { opacity: 1 }, 30 | transition: { duration: 0.01 }, 31 | }) 32 | 33 | await new Promise((resolve) => { 34 | element.addEventListener("pressstart", resolve) 35 | pointerDown(element) 36 | }) 37 | }) 38 | 39 | test("Press fires pressend event when press is triggered", async () => { 40 | const { element } = createTestMotionState({ 41 | press: { opacity: 1 }, 42 | transition: { duration: 0.01 }, 43 | }) 44 | 45 | await new Promise((resolve) => { 46 | element.addEventListener("pressend", resolve) 47 | pointerDown(element) 48 | fireEvent.pointerUp(window) 49 | }) 50 | }) 51 | 52 | test("Animate to new press pose while press is active", async () => { 53 | const { element, state } = createTestMotionState({ 54 | press: { opacity: 0.5 }, 55 | transition: { duration: 0.01 }, 56 | }) 57 | 58 | await new Promise((resolve) => { 59 | pointerDown(element) 60 | 61 | requestAnimationFrame(() => { 62 | state.update({ 63 | press: { opacity: 0.75 }, 64 | transition: { duration: 0.01 }, 65 | }) 66 | element.addEventListener("motioncomplete", resolve) 67 | }) 68 | }) 69 | 70 | expect(element).toHaveStyle("opacity: 0.75") 71 | }) 72 | 73 | test("If hover changes while overridden by press, don't animate", async () => { 74 | const { element, state } = createTestMotionState({ 75 | hover: { opacity: 0.5 }, 76 | press: { opacity: 0.75 }, 77 | transition: { duration: 0.01 }, 78 | }) 79 | 80 | await new Promise((resolve) => { 81 | pointerEnter(element) 82 | 83 | requestAnimationFrame(() => { 84 | pointerDown(element) 85 | state.update({ 86 | hover: { opacity: 0.25 }, 87 | press: { opacity: 0.75 }, 88 | transition: { duration: 0.01 }, 89 | }) 90 | 91 | setTimeout(() => { 92 | resolve() 93 | }, 100) 94 | }) 95 | }) 96 | 97 | expect(element).toHaveStyle("opacity: 0.75") 98 | }) 99 | 100 | test("Animate from press to hover when press ends", async () => { 101 | const { element } = createTestMotionState({ 102 | hover: { opacity: 0.5 }, 103 | press: { opacity: 0.75 }, 104 | transition: { duration: 0.01 }, 105 | }) 106 | 107 | element.style.opacity = "1" 108 | 109 | await new Promise((resolve) => { 110 | pointerEnter(element) 111 | 112 | requestAnimationFrame(() => { 113 | pointerDown(element) 114 | 115 | setTimeout(() => { 116 | fireEvent.pointerUp(window) 117 | setTimeout(() => { 118 | resolve() 119 | }, 100) 120 | }, 50) 121 | }) 122 | }) 123 | 124 | expect(element).toHaveStyle("opacity: 0.5") 125 | }) 126 | 127 | test("Animate from press to style when press ends and hover is inactive", async () => { 128 | const { element } = createTestMotionState({ 129 | hover: { opacity: 0.5 }, 130 | press: { opacity: 0.75 }, 131 | transition: { duration: 0.01 }, 132 | }) 133 | element.style.opacity = "1" 134 | 135 | await new Promise((resolve) => { 136 | pointerEnter(element) 137 | 138 | requestAnimationFrame(() => { 139 | pointerDown(element) 140 | 141 | setTimeout(() => { 142 | pointerLeave(element) 143 | fireEvent.pointerUp(window) 144 | 145 | setTimeout(() => { 146 | resolve() 147 | }, 50) 148 | }, 10) 149 | }) 150 | }) 151 | 152 | expect(element).toHaveStyle("opacity: 1") 153 | }) 154 | }) 155 | -------------------------------------------------------------------------------- /packages/dom/src/state/__tests__/utils.ts: -------------------------------------------------------------------------------- 1 | import { createMotionState } from ".." 2 | import { style } from "../../animate/style" 3 | import { createStyles } from "../../animate/utils/style-object" 4 | import { MotionState, Options } from "../types" 5 | 6 | export function createTestMotionState(options: Options, parent?: MotionState) { 7 | const element = document.createElement("div") 8 | 9 | const state = createMotionState(options, parent) 10 | 11 | state.mount(element) 12 | 13 | const initialStyles = createStyles(state.getTarget()) 14 | for (const key in initialStyles) { 15 | style.set(element, key, initialStyles[key]) 16 | } 17 | 18 | state.update(options) 19 | 20 | return { element, state } 21 | } 22 | -------------------------------------------------------------------------------- /packages/dom/src/state/gestures/hover.ts: -------------------------------------------------------------------------------- 1 | import { dispatchPointerEvent } from "../utils/events" 2 | import { Gesture } from "./types" 3 | 4 | const mouseEvent = 5 | (element: Element, name: "hoverstart" | "hoverend", action: VoidFunction) => 6 | (event: PointerEvent) => { 7 | if (event.pointerType && event.pointerType !== "mouse") return 8 | action() 9 | dispatchPointerEvent(element, name, event) 10 | } 11 | 12 | export const hover: Gesture = { 13 | isActive: (options) => Boolean(options.hover), 14 | subscribe: (element, { enable, disable }) => { 15 | const onEnter = mouseEvent(element, "hoverstart", enable) 16 | const onLeave = mouseEvent(element, "hoverend", disable) 17 | 18 | element.addEventListener("pointerenter", onEnter as EventListener) 19 | element.addEventListener("pointerleave", onLeave as EventListener) 20 | 21 | return () => { 22 | element.removeEventListener("pointerenter", onEnter as EventListener) 23 | element.removeEventListener("pointerleave", onLeave as EventListener) 24 | } 25 | }, 26 | } 27 | -------------------------------------------------------------------------------- /packages/dom/src/state/gestures/in-view.ts: -------------------------------------------------------------------------------- 1 | import { dispatchViewEvent } from "../utils/events" 2 | import { Gesture } from "./types" 3 | import { inView as inViewDom } from "../../gestures/in-view" 4 | 5 | export const inView: Gesture = { 6 | isActive: (options) => Boolean(options.inView), 7 | subscribe: (element, { enable, disable }, { inViewOptions = {} }) => { 8 | const { once, ...viewOptions } = inViewOptions 9 | 10 | return inViewDom( 11 | element, 12 | (enterEntry) => { 13 | enable() 14 | dispatchViewEvent(element, "viewenter", enterEntry) 15 | 16 | if (!once) { 17 | return (leaveEntry) => { 18 | disable() 19 | dispatchViewEvent(element, "viewleave", leaveEntry) 20 | } 21 | } 22 | }, 23 | viewOptions 24 | ) 25 | }, 26 | } 27 | -------------------------------------------------------------------------------- /packages/dom/src/state/gestures/press.ts: -------------------------------------------------------------------------------- 1 | import { dispatchPointerEvent } from "../utils/events" 2 | import { Gesture } from "./types" 3 | 4 | export const press: Gesture = { 5 | isActive: (options) => Boolean(options.press), 6 | subscribe: (element, { enable, disable }) => { 7 | const onPointerUp = (event: PointerEvent) => { 8 | disable() 9 | dispatchPointerEvent(element, "pressend", event) 10 | window.removeEventListener("pointerup", onPointerUp) 11 | } 12 | 13 | const onPointerDown = (event: PointerEvent) => { 14 | enable() 15 | dispatchPointerEvent(element, "pressstart", event) 16 | window.addEventListener("pointerup", onPointerUp) 17 | } 18 | 19 | element.addEventListener("pointerdown", onPointerDown as EventListener) 20 | 21 | return () => { 22 | element.removeEventListener("pointerdown", onPointerDown as EventListener) 23 | window.removeEventListener("pointerup", onPointerUp) 24 | } 25 | }, 26 | } 27 | -------------------------------------------------------------------------------- /packages/dom/src/state/gestures/types.ts: -------------------------------------------------------------------------------- 1 | import { Options } from "../types" 2 | 3 | export interface StateHandlers { 4 | enable: VoidFunction 5 | disable: VoidFunction 6 | } 7 | 8 | export interface Gesture { 9 | isActive: (options: Options) => boolean 10 | subscribe: ( 11 | element: Element, 12 | stateHandlers: StateHandlers, 13 | options: Options 14 | ) => () => void 15 | } 16 | -------------------------------------------------------------------------------- /packages/dom/src/state/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AnimationOptionsWithOverrides, 3 | MotionKeyframes, 4 | MotionKeyframesDefinition, 5 | } from "../animate/types" 6 | import { InViewOptions } from "../gestures/in-view" 7 | 8 | export interface Target { 9 | [key: string]: string | number 10 | } 11 | 12 | export interface MotionState { 13 | update: (options: Options) => void 14 | getDepth: () => number 15 | getTarget: () => MotionKeyframes 16 | getOptions: () => Options 17 | getContext: () => MotionStateContext 18 | setActive: (type: keyof MotionStateContext, isActive: boolean) => void 19 | mount: (element: Element) => () => void 20 | isMounted: () => boolean 21 | animateUpdates: () => Generator 22 | } 23 | 24 | export interface Options { 25 | initial?: false | VariantDefinition 26 | animate?: VariantDefinition 27 | inView?: VariantDefinition 28 | hover?: VariantDefinition 29 | press?: VariantDefinition 30 | variants?: Variants 31 | transition?: AnimationOptionsWithOverrides 32 | inViewOptions?: InViewOptions & { once?: boolean } 33 | } 34 | 35 | export interface MotionStateContext { 36 | initial?: string 37 | animate?: string 38 | inView?: string 39 | hover?: string 40 | press?: string 41 | exit?: string 42 | } 43 | 44 | export type Variant = MotionKeyframesDefinition & { 45 | transition?: AnimationOptionsWithOverrides 46 | } 47 | 48 | export interface Variants { 49 | [key: string]: Variant 50 | } 51 | 52 | export type VariantDefinition = Variant | string 53 | 54 | export type MotionEventNames = 55 | | "motionstart" 56 | | "motioncomplete" 57 | | "hoverstart" 58 | | "hoverend" 59 | | "pressstart" 60 | | "pressend" 61 | | "viewenter" 62 | | "viewleave" 63 | 64 | export type MotionEvent = CustomEvent<{ 65 | target: Variant 66 | }> 67 | 68 | export type CustomPointerEvent = CustomEvent<{ 69 | originalEvent: PointerEvent 70 | }> 71 | 72 | export type ViewEvent = CustomEvent<{ 73 | originalEntry: IntersectionObserverEntry 74 | }> 75 | 76 | declare global { 77 | interface GlobalEventHandlersEventMap { 78 | motionstart: MotionEvent 79 | motioncomplete: MotionEvent 80 | hoverstart: CustomPointerEvent 81 | hoverend: CustomPointerEvent 82 | pressstart: CustomPointerEvent 83 | pressend: CustomPointerEvent 84 | viewenter: ViewEvent 85 | viewleave: ViewEvent 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /packages/dom/src/state/utils/events.ts: -------------------------------------------------------------------------------- 1 | import { MotionKeyframesDefinition } from "../../animate/types" 2 | import { MotionEventNames } from "../types" 3 | 4 | export const motionEvent = ( 5 | name: MotionEventNames, 6 | target: MotionKeyframesDefinition 7 | ) => new CustomEvent(name, { detail: { target } }) 8 | 9 | export function dispatchPointerEvent( 10 | element: Element, 11 | name: MotionEventNames, 12 | event: PointerEvent 13 | ) { 14 | element.dispatchEvent( 15 | new CustomEvent(name, { detail: { originalEvent: event } }) 16 | ) 17 | } 18 | 19 | export function dispatchViewEvent( 20 | element: Element, 21 | name: MotionEventNames, 22 | entry?: IntersectionObserverEntry 23 | ) { 24 | element.dispatchEvent( 25 | new CustomEvent(name, { detail: { originalEntry: entry } }) 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /packages/dom/src/state/utils/has-changed.ts: -------------------------------------------------------------------------------- 1 | export function hasChanged(a: any, b: any): boolean { 2 | if (typeof a !== typeof b) return true 3 | if (Array.isArray(a) && Array.isArray(b)) return !shallowCompare(a, b) 4 | return a !== b 5 | } 6 | 7 | export function shallowCompare(next: any[], prev: any[]) { 8 | const prevLength = prev.length 9 | 10 | if (prevLength !== next.length) return false 11 | 12 | for (let i = 0; i < prevLength; i++) { 13 | if (prev[i] !== next[i]) return false 14 | } 15 | 16 | return true 17 | } 18 | -------------------------------------------------------------------------------- /packages/dom/src/state/utils/is-variant.ts: -------------------------------------------------------------------------------- 1 | import { Variant, VariantDefinition } from "../types" 2 | 3 | export function isVariant( 4 | definition: VariantDefinition | undefined 5 | ): definition is Variant { 6 | return typeof definition === "object" 7 | } 8 | -------------------------------------------------------------------------------- /packages/dom/src/state/utils/resolve-variant.ts: -------------------------------------------------------------------------------- 1 | import type { Variants, VariantDefinition, Variant } from "../types" 2 | import { isVariant } from "./is-variant" 3 | 4 | export function resolveVariant( 5 | definition?: VariantDefinition, 6 | variants?: Variants 7 | ): Variant | undefined { 8 | if (isVariant(definition)) { 9 | return definition 10 | } else if (definition && variants) { 11 | return variants[definition] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/dom/src/state/utils/schedule.ts: -------------------------------------------------------------------------------- 1 | import { addUniqueItem, removeItem } from "@motionone/utils" 2 | import { MotionState } from "../types" 3 | 4 | let scheduled: MotionState[] | undefined = undefined 5 | 6 | function processScheduledAnimations() { 7 | if (!scheduled) return 8 | 9 | const generators = scheduled.sort(compareByDepth).map(fireAnimateUpdates) 10 | 11 | generators.forEach(fireNext) 12 | generators.forEach(fireNext) 13 | 14 | scheduled = undefined 15 | } 16 | 17 | export function scheduleAnimation(state: MotionState) { 18 | if (!scheduled) { 19 | scheduled = [state] 20 | requestAnimationFrame(processScheduledAnimations) 21 | } else { 22 | addUniqueItem(scheduled, state) 23 | } 24 | } 25 | 26 | export function unscheduleAnimation(state: MotionState) { 27 | scheduled && removeItem(scheduled, state) 28 | } 29 | 30 | const compareByDepth = (a: MotionState, b: MotionState) => 31 | a.getDepth() - b.getDepth() 32 | 33 | const fireAnimateUpdates = (state: MotionState) => state.animateUpdates() 34 | 35 | const fireNext = (iterator: Iterator) => iterator.next() 36 | -------------------------------------------------------------------------------- /packages/dom/src/timeline/README.md: -------------------------------------------------------------------------------- 1 | # `timeline` 2 | 3 | Create complex sequences of animations across multiple elements. 4 | 5 | ```ts 6 | import { timeline } from "motion" 7 | 8 | timeline(sequence, options) 9 | ``` 10 | 11 | ## Sequence 12 | 13 | The timeline sequence is an array: 14 | 15 | ```ts 16 | const sequence = [] 17 | ``` 18 | 19 | This array accepts animate definitions: 20 | 21 | ```ts 22 | const sequence = [["nav", { x: 100 }, { duration: 1 }]] 23 | ``` 24 | 25 | By default, each animation will play in sequence, one after the other: 26 | 27 | ```ts 28 | const sequence = [ 29 | ["nav", { x: 100 }, { duration: 1 }], 30 | ["nav li", { opacity: 1 }, { duration: 0.3, delay: stagger(0.1) }], 31 | ] 32 | ``` 33 | 34 | ### `at` 35 | 36 | The timing of each animations can be adjusted with the at option. 37 | 38 | Pass a number to define a specific time: 39 | 40 | ```ts 41 | const sequence = [ 42 | ["nav", { opacity: 1 }], 43 | // This will start 0.5 from the start of the whole timeline: 44 | ["nav", { x: 100 }, { at: 0.5 }], 45 | ] 46 | ``` 47 | 48 | Pass a number as string starting with + or - to start relative to the end of the previous animation: 49 | 50 | ```ts 51 | const sequence = [ 52 | ["nav", { opacity: 1 }], 53 | // This will start 0.5 seconds after the previous animation: 54 | ["nav", { x: 100 }, { at: "+0.5" }], 55 | // This will start 0.2 seconds before the end of the previous animation: 56 | ["nav li", { opacity: 1 }, { at: "-0.2" }], 57 | ] 58 | ``` 59 | 60 | Or pass "<" to start at the same time as the previous animation: 61 | 62 | ```ts 63 | const sequence = [ 64 | ["nav", { opacity: 1 }, { duration: 1 }], 65 | ["nav", { x: 100 }, { duration: 1 }], 66 | // This will start at the same time as the x: 100 animation 67 | ["nav li", { opacity: 1 }, { at: "<" }], 68 | ] 69 | ``` 70 | 71 | ### Labels 72 | 73 | By passing a string in the sequence you can mark that time with a label, to later refer to it with an at: 74 | 75 | ```ts 76 | const sequence = [ 77 | ["nav", { opacity: 1 }, { duration: 2 }], 78 | "my label", 79 | 80 | ``` 81 | 82 | In the above example, "my label" will be set to the 2 second mark. Later in the sequence, you can refer to the 2 second mark in at by using this label: 83 | 84 | ```ts 85 | ;["nav li", { opacity: 1 }, { at: "my label" }] 86 | ``` 87 | 88 | Alternatively, a label can be defined absolutely or relatively by passing it as an object with its own `at` property: 89 | 90 | ```ts 91 | const sequence = [ 92 | ["nav", { opacity: 1 }, { duration: 2 }], 93 | { name: "my label", at: "-0.5" } 94 | ``` 95 | 96 | Here, "my label" will be set to the 1.5 second mark. 97 | 98 | ### Forward-filling keyframes 99 | 100 | When defining a segment with multiple keyframes, the first keyframe will be forward-filled to the start of the animation. 101 | 102 | So in this example, button elements will be set to opacity: 0 at the very start of the animation, and then begin animating after 0.5 seconds: 103 | 104 | ```ts 105 | const sequence = [["button", { opacity: [0, 1] }, { at: 0.5 }]] 106 | ``` 107 | 108 | ## Options 109 | 110 | ### `duration` 111 | 112 | **Default:** Automatically calculated 113 | 114 | A duration, in seconds, that the animation will take to complete. 115 | 116 | ```ts 117 | timeline(sequence, { duration: 4 }) 118 | ``` 119 | 120 | By default, this is automatically calculated by the provided sequence. But if provided explicitly, the whole animation will be scaled to fit this duration. 121 | 122 | ### `delay` 123 | 124 | **Default:** 0 125 | 126 | A duration, in seconds, that the animation will be delayed before starting. 127 | 128 | ```ts 129 | timeline(sequence, { delay: 0.5 }) 130 | ``` 131 | 132 | ### `endDelay` 133 | 134 | **Default:** 0 135 | 136 | A duration, in seconds, that the animation will wait at the end before ending. 137 | 138 | ```ts 139 | timeline(sequence, { endDelay: 0.5 }) 140 | ``` 141 | 142 | ### `direction` 143 | 144 | **Default:** "normal" 145 | 146 | The direction of animation playback. "normal", "reverse", "alternate", or "alternate-reverse". 147 | 148 | ```ts 149 | timeline(sequence, { direction: "alternate", repeat: 2 }) 150 | ``` 151 | 152 | ### `repeat` 153 | 154 | **Default:** 0 155 | 156 | The number of times the animation should repeat. Set to Infinity to repeat indefinitely. 157 | 158 | ```ts 159 | timeline(sequence, { repeat: 2 }) 160 | ``` 161 | 162 | ### `defaultOptions` 163 | 164 | An object of options to use as the default options for each animation in the sequence. 165 | 166 | ```ts 167 | timeline(sequence, { 168 | defaultOptions: { ease: "ease-in-out" }, 169 | }) 170 | ``` 171 | 172 | ## Returns 173 | 174 | `timeline()` returns [AnimationControls](https://motion.dev/docs/animate#controls). 175 | -------------------------------------------------------------------------------- /packages/dom/src/timeline/types.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AnimationListOptions, 3 | MotionKeyframesDefinition, 4 | } from "../animate/types" 5 | import type { Easing } from "@motionone/types" 6 | import { ElementOrSelector } from "../types" 7 | 8 | export interface AnnotatedLabel { 9 | name: string 10 | at: NextTime 11 | } 12 | 13 | export type TimelineSegment = 14 | | [ElementOrSelector, MotionKeyframesDefinition] 15 | | [ElementOrSelector, MotionKeyframesDefinition, AnimationListOptions] 16 | | string 17 | | AnnotatedLabel 18 | 19 | export type TimelineDefinition = TimelineSegment[] 20 | 21 | export type NextTime = number | "<" | `+${number}` | `-${number}` | `${string}` 22 | 23 | export interface ElementSequence { 24 | [key: string]: ValueSequence 25 | } 26 | 27 | export type AbsoluteKeyframe = { 28 | value: string | number | null 29 | at: number 30 | easing?: Easing 31 | } 32 | 33 | export type ValueSequence = AbsoluteKeyframe[] 34 | -------------------------------------------------------------------------------- /packages/dom/src/timeline/utils/__tests__/calc-time.test.ts: -------------------------------------------------------------------------------- 1 | import { calcNextTime } from "../calc-time" 2 | 3 | describe("calcNextTime", () => { 4 | test("Correctly returns a new time based on the past arguments", () => { 5 | const labels = new Map() 6 | labels.set("foo", 2) 7 | 8 | expect(calcNextTime(1, 0.2, 100, labels)).toBe(0.2) 9 | expect(calcNextTime(2, 0.2, 100, labels)).toBe(0.2) 10 | expect(calcNextTime(4, "foo", 100, labels)).toBe(2) 11 | expect(calcNextTime(4, "bar", 100, labels)).toBe(4) 12 | expect(calcNextTime(5, "-1", 100, labels)).toBe(4) 13 | expect(calcNextTime(5, "+1", 100, labels)).toBe(6) 14 | expect(calcNextTime(5, "-7", 100, labels)).toBe(0) 15 | expect(calcNextTime(5, "<", 100, labels)).toBe(100) 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /packages/dom/src/timeline/utils/__tests__/edit.test.ts: -------------------------------------------------------------------------------- 1 | import { ValueSequence } from "../../types" 2 | import { addKeyframes, eraseKeyframes } from "../edit" 3 | 4 | describe("eraseKeyframes", () => { 5 | test("Erase keyframes between the specified time range", () => { 6 | const sequence: ValueSequence = [ 7 | { value: 1, at: 50 }, 8 | { value: 2, at: 300 }, 9 | { value: 3, at: 299 }, 10 | { value: 4, at: 101 }, 11 | { value: 5, at: 100 }, 12 | { value: 6, at: 350 }, 13 | ] 14 | 15 | eraseKeyframes(sequence, 100, 300) 16 | 17 | expect(sequence).toEqual([ 18 | { value: 1, at: 50 }, 19 | { value: 2, at: 300 }, 20 | { value: 5, at: 100 }, 21 | { value: 6, at: 350 }, 22 | ]) 23 | }) 24 | }) 25 | 26 | describe("addKeyframes", () => { 27 | test("Adds keyframes to sequence", () => { 28 | const sequence: ValueSequence = [{ value: 1, at: 50 }] 29 | 30 | addKeyframes( 31 | sequence, 32 | [1, 2, 3, 4], 33 | [0, 1, 2, 3], 34 | [0, 0.1, 0.5, 1], 35 | 500, 36 | 1000 37 | ) 38 | 39 | expect(sequence).toEqual([ 40 | { value: 1, at: 50 }, 41 | { value: 1, at: 500, easing: [0, 1, 2, 3] }, 42 | { value: 2, at: 550, easing: [0, 1, 2, 3] }, 43 | { value: 3, at: 750, easing: [0, 1, 2, 3] }, 44 | { value: 4, at: 1000, easing: [0, 1, 2, 3] }, 45 | ]) 46 | 47 | addKeyframes( 48 | sequence, 49 | [5, 6, 7], 50 | ["ease-in", "ease-in-out"], 51 | [0, 0.5, 1], 52 | 400, 53 | 600 54 | ) 55 | 56 | expect(sequence).toEqual([ 57 | { value: 1, at: 50 }, 58 | { value: 3, at: 750, easing: [0, 1, 2, 3] }, 59 | { value: 4, at: 1000, easing: [0, 1, 2, 3] }, 60 | { value: 5, at: 400, easing: "ease-in" }, 61 | { value: 6, at: 500, easing: "ease-in-out" }, 62 | { value: 7, at: 600, easing: "ease-in" }, 63 | ]) 64 | }) 65 | }) 66 | -------------------------------------------------------------------------------- /packages/dom/src/timeline/utils/__tests__/sort.test.ts: -------------------------------------------------------------------------------- 1 | import { ValueSequence } from "../../types" 2 | import { compareByTime } from "../sort" 3 | 4 | describe("compareByTime", () => { 5 | test("Can be used to sort values by at time", () => { 6 | const sequence: ValueSequence = [ 7 | { value: 0, at: 300, easing: "ease" }, 8 | { value: 1, at: 0, easing: "ease" }, 9 | { value: 2, at: 301, easing: "ease" }, 10 | { value: 3, at: 299, easing: "ease" }, 11 | { value: 4, at: 40, easing: "ease" }, 12 | ] 13 | 14 | expect(sequence.sort(compareByTime)).toEqual([ 15 | { value: 1, at: 0, easing: "ease" }, 16 | { value: 4, at: 40, easing: "ease" }, 17 | { value: 3, at: 299, easing: "ease" }, 18 | { value: 0, at: 300, easing: "ease" }, 19 | { value: 2, at: 301, easing: "ease" }, 20 | ]) 21 | }) 22 | 23 | test("Will correctly swap values so null comes second if at time the same", () => { 24 | const sequence: ValueSequence = [ 25 | { value: null, at: 300, easing: "ease" }, 26 | { value: 1, at: 0, easing: "ease" }, 27 | { value: 2, at: 300, easing: "ease" }, 28 | { value: 3, at: 299, easing: "ease" }, 29 | { value: 4, at: 40, easing: "ease" }, 30 | ] 31 | 32 | expect(sequence.sort(compareByTime)).toEqual([ 33 | { value: 1, at: 0, easing: "ease" }, 34 | { value: 4, at: 40, easing: "ease" }, 35 | { value: 3, at: 299, easing: "ease" }, 36 | { value: 2, at: 300, easing: "ease" }, 37 | { value: null, at: 300, easing: "ease" }, 38 | ]) 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /packages/dom/src/timeline/utils/calc-time.ts: -------------------------------------------------------------------------------- 1 | import type { NextTime } from "../types" 2 | import { isNumber } from "@motionone/utils" 3 | 4 | export function calcNextTime( 5 | current: number, 6 | next: NextTime, 7 | prev: number, 8 | labels: Map 9 | ): number { 10 | if (isNumber(next)) { 11 | return next 12 | } else if (next.startsWith("-") || next.startsWith("+")) { 13 | return Math.max(0, current + parseFloat(next)) 14 | } else if (next === "<") { 15 | return prev 16 | } else { 17 | return labels.get(next) ?? current 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/dom/src/timeline/utils/edit.ts: -------------------------------------------------------------------------------- 1 | import type { Easing, UnresolvedValueKeyframe } from "@motionone/types" 2 | import type { ValueSequence } from "../types" 3 | import { getEasingForSegment, removeItem, mix } from "@motionone/utils" 4 | 5 | export function eraseKeyframes( 6 | sequence: ValueSequence, 7 | startTime: number, 8 | endTime: number 9 | ): void { 10 | for (let i = 0; i < sequence.length; i++) { 11 | const keyframe = sequence[i] 12 | 13 | if (keyframe.at > startTime && keyframe.at < endTime) { 14 | removeItem(sequence, keyframe) 15 | 16 | // If we remove this item we have to push the pointer back one 17 | i-- 18 | } 19 | } 20 | } 21 | 22 | export function addKeyframes( 23 | sequence: ValueSequence, 24 | keyframes: UnresolvedValueKeyframe[], 25 | easing: Easing | Easing[], 26 | offset: number[], 27 | startTime: number, 28 | endTime: number 29 | ): void { 30 | /** 31 | * Erase every existing value between currentTime and targetTime, 32 | * this will essentially splice this timeline into any currently 33 | * defined ones. 34 | */ 35 | eraseKeyframes(sequence, startTime, endTime) 36 | 37 | for (let i = 0; i < keyframes.length; i++) { 38 | sequence.push({ 39 | value: keyframes[i], 40 | at: mix(startTime, endTime, offset[i]), 41 | easing: getEasingForSegment(easing, i), 42 | }) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/dom/src/timeline/utils/sort.ts: -------------------------------------------------------------------------------- 1 | import type { AbsoluteKeyframe } from "../types" 2 | 3 | export function compareByTime( 4 | a: AbsoluteKeyframe, 5 | b: AbsoluteKeyframe 6 | ): number { 7 | if (a.at === b.at) { 8 | return a.value === null ? 1 : -1 9 | } else { 10 | return a.at - b.at 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/dom/src/types.ts: -------------------------------------------------------------------------------- 1 | import type { MotionValue } from "@motionone/types" 2 | 3 | export interface ElementAnimationData { 4 | transforms: string[] 5 | values: Map 6 | } 7 | 8 | export type ElementOrSelector = 9 | | Element 10 | | Element[] 11 | | NodeListOf 12 | | string 13 | -------------------------------------------------------------------------------- /packages/dom/src/utils/__tests__/stagger.test.ts: -------------------------------------------------------------------------------- 1 | import { getEasingFunction } from "@motionone/animation" 2 | import { stagger, getFromIndex } from "../stagger" 3 | 4 | describe("stagger", () => { 5 | test("Creates a stagger function", () => { 6 | expect(stagger(0.1)(0, 10)).toEqual(0) 7 | expect(stagger(0.1)(2, 10)).toEqual(0.2) 8 | }) 9 | 10 | test("Accepts start", () => { 11 | expect(stagger(0.1, { start: 0.2 })(0, 10)).toEqual(0.2) 12 | expect(stagger(0.1, { start: 0.2 })(2, 10)).toEqual(0.4) 13 | }) 14 | 15 | test("Accepts from", () => { 16 | expect(stagger(0.1, { from: 2 })(0, 10)).toEqual(0.2) 17 | expect(stagger(0.1, { from: "first" })(2, 10)).toEqual(0.2) 18 | expect(stagger(0.1, { from: "last" })(9, 10)).toEqual(0) 19 | expect(stagger(0.1, { from: "last" })(5, 10)).toEqual(0.4) 20 | expect(stagger(0.1, { from: "center" })(2, 10)).toEqual(0.25) 21 | }) 22 | 23 | test("Accepts easing", () => { 24 | expect(stagger(0.1, { easing: "linear" })(5, 10)).toEqual(0.5) 25 | 26 | const expectedEaseIn = getEasingFunction("ease-in")(0.5) 27 | expect(stagger(0.1, { easing: "ease-in" })(5, 10)).toEqual(expectedEaseIn) 28 | 29 | const expectedEaseOut = getEasingFunction("ease-out")(0.5) 30 | expect(stagger(0.1, { easing: "ease-out" })(5, 10)).toEqual(expectedEaseOut) 31 | 32 | expect(stagger(0.1, { easing: (v: number) => v / 2 })(4, 10)).toEqual(0.2) 33 | }) 34 | }) 35 | 36 | describe("getFromIndex", () => { 37 | test("Returns correct index", () => { 38 | expect(getFromIndex("first", 9)).toEqual(0) 39 | expect(getFromIndex("last", 9)).toEqual(8) 40 | expect(getFromIndex("center", 9)).toEqual(4) 41 | expect(getFromIndex("center", 10)).toEqual(4.5) 42 | }) 43 | }) 44 | -------------------------------------------------------------------------------- /packages/dom/src/utils/resolve-elements.ts: -------------------------------------------------------------------------------- 1 | import { ElementOrSelector } from "../types" 2 | 3 | export function resolveElements( 4 | elements: ElementOrSelector, 5 | selectorCache?: { [key: string]: NodeListOf } 6 | ): Element[] { 7 | if (typeof elements === "string") { 8 | if (selectorCache) { 9 | selectorCache[elements] ??= document.querySelectorAll(elements) 10 | elements = selectorCache[elements] 11 | } else { 12 | elements = document.querySelectorAll(elements) 13 | } 14 | } else if (elements instanceof Element) { 15 | elements = [elements] 16 | } 17 | 18 | /** 19 | * Return an empty array 20 | */ 21 | return Array.from(elements || []) 22 | } 23 | -------------------------------------------------------------------------------- /packages/dom/src/utils/stagger.ts: -------------------------------------------------------------------------------- 1 | import type { Easing, OptionResolver, EasingFunction } from "@motionone/types" 2 | import { isNumber, isFunction } from "@motionone/utils" 3 | import { getEasingFunction } from "@motionone/animation" 4 | 5 | export type From = "first" | "last" | "center" | number 6 | 7 | export type StaggerOptions = { 8 | start?: number 9 | from?: From 10 | easing?: EasingFunction | Easing 11 | } 12 | 13 | export function stagger( 14 | duration: number = 0.1, 15 | { start = 0, from = 0, easing }: StaggerOptions = {} 16 | ): OptionResolver { 17 | return (i: number, total: number) => { 18 | const fromIndex = isNumber(from) ? from : getFromIndex(from, total) 19 | const distance = Math.abs(fromIndex - i) 20 | let delay = duration * distance 21 | 22 | if (easing) { 23 | const maxDelay = total * duration 24 | const easingFunction = getEasingFunction(easing) 25 | delay = easingFunction(delay / maxDelay) * maxDelay 26 | } 27 | 28 | return start + delay 29 | } 30 | } 31 | 32 | export function getFromIndex(from: From, total: number) { 33 | if (from === "first") { 34 | return 0 35 | } else { 36 | const lastIndex = total - 1 37 | return from === "last" ? lastIndex : lastIndex / 2 38 | } 39 | } 40 | 41 | export function resolveOption( 42 | option: T | OptionResolver, 43 | i: number, 44 | total: number 45 | ) { 46 | return isFunction(option) ? (option as OptionResolver)(i, total) : option 47 | } 48 | -------------------------------------------------------------------------------- /packages/dom/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "config/ts-base.json", 3 | "$schema": "https://json.schemastore.org/tsconfig", 4 | "include": ["src/**/*.ts", "**/*.ts", "**/*.tsx"], 5 | "exclude": ["**/__tests__/*"], 6 | "compilerOptions": { 7 | "rootDir": "./src", 8 | "outDir": "./lib", 9 | "declarationDir": "./types" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/dom/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path") 2 | 3 | module.exports = { 4 | mode: "production", 5 | entry: { 6 | "size-webpack-animate": path.join(__dirname, "./lib/animate/index.js"), 7 | }, 8 | output: { 9 | filename: "[name].js", 10 | path: path.resolve(__dirname, "dist"), 11 | library: { 12 | type: "module", 13 | }, 14 | }, 15 | experiments: { 16 | outputModule: true, 17 | }, 18 | resolve: { extensions: [".wasm", ".mjs", ".js", ".jsx", ".json"] }, 19 | module: { 20 | rules: [ 21 | { 22 | test: /\.m?js/, 23 | resolve: { 24 | fullySpecified: false, 25 | }, 26 | }, 27 | ], 28 | }, 29 | } 30 | -------------------------------------------------------------------------------- /packages/easing/.npmignore: -------------------------------------------------------------------------------- 1 | src/** 2 | .turbo 3 | tsconfig.json 4 | rollup.config.js 5 | README.md 6 | coverage/** 7 | jest.* -------------------------------------------------------------------------------- /packages/easing/README.md: -------------------------------------------------------------------------------- 1 | # `@motionone/easing` 2 | 3 | Easing functions for Motion One. 4 | 5 | ## 📚 Documentation 6 | 7 | Full docs for Motion One available at [motion.dev](https://motion.dev). 8 | -------------------------------------------------------------------------------- /packages/easing/jest.config.d.ts: -------------------------------------------------------------------------------- 1 | export = config; 2 | /** @type {import('@jest/types').Config.InitialOptions} */ 3 | declare const config: import('@jest/types').Config.InitialOptions; 4 | //# sourceMappingURL=jest.config.d.ts.map -------------------------------------------------------------------------------- /packages/easing/jest.config.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"jest.config.d.ts","sourceRoot":"","sources":["jest.config.js"],"names":[],"mappings":";AAEA,0DAA0D;AAC1D,sBADW,OAAO,aAAa,EAAE,MAAM,CAAC,cAAc,CAIrD"} -------------------------------------------------------------------------------- /packages/easing/jest.config.js: -------------------------------------------------------------------------------- 1 | const baseConfig = require("config/jest.config") 2 | 3 | /** @type {import('@jest/types').Config.InitialOptions} */ 4 | const config = { 5 | ...baseConfig, 6 | setupFilesAfterEnv: ["/../../config/jest.setup.ts"], 7 | } 8 | 9 | module.exports = config 10 | -------------------------------------------------------------------------------- /packages/easing/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@motionone/easing", 3 | "version": "10.18.0", 4 | "description": "A collection of easing functions.", 5 | "license": "MIT", 6 | "author": "Matt Perry", 7 | "main": "dist/index.cjs.js", 8 | "module": "dist/index.es.js", 9 | "types": "types/index.d.ts", 10 | "sideEffects": false, 11 | "scripts": { 12 | "build": "rimraf lib dist types && tsc -p . && rollup -c", 13 | "test": "jest --coverage --config jest.config.js", 14 | "dev": "concurrently -c blue,red -n tsc,rollup --kill-others \"tsc --watch -p . --preserveWatchOutput\" \"rollup --c --watch --no-watch.clearScreen\"", 15 | "measure": "bundlesize" 16 | }, 17 | "dependencies": { 18 | "@motionone/utils": "^10.18.0", 19 | "tslib": "^2.3.1" 20 | }, 21 | "bundlesize": [ 22 | { 23 | "path": "./dist/size-index.js", 24 | "maxSize": "0.5 kB" 25 | } 26 | ], 27 | "gitHead": "1c67c845fb4032c9d27f3761094939b30b759f9e" 28 | } 29 | -------------------------------------------------------------------------------- /packages/easing/rollup.config.js: -------------------------------------------------------------------------------- 1 | const { createDistBuild, createSizeBuild } = require("config/rollup.config") 2 | const pkg = require("./package.json") 3 | 4 | const sizeBundles = [["index.js", "size-index.js"]].map(([input, output]) => 5 | createSizeBuild({ input: `lib/${input}`, output: `dist/${output}` }, pkg) 6 | ) 7 | 8 | module.exports = [...createDistBuild(pkg), ...sizeBundles] 9 | -------------------------------------------------------------------------------- /packages/easing/src/__tests__/cubic-bezer.test.ts: -------------------------------------------------------------------------------- 1 | import { cubicBezier } from "../cubic-bezier" 2 | 3 | describe("cubicBezier", () => { 4 | test("correctly generates easing functions from curve definitions", () => { 5 | const linear = cubicBezier(0, 0, 1, 1) 6 | expect(linear(0)).toBe(0) 7 | expect(linear(1)).toBe(1) 8 | expect(linear(0.5)).toBe(0.5) 9 | 10 | const curve = cubicBezier(0.5, 0.1, 0.31, 0.96) 11 | expect(curve(0)).toBe(0) 12 | expect(curve(0.01)).toBeCloseTo(0.002, 2) 13 | expect(curve(0.25)).toBeCloseTo(0.164, 2) 14 | expect(curve(0.75)).toBeCloseTo(0.935, 2) 15 | expect(curve(0.99)).toBeCloseTo(0.999, 2) 16 | expect(curve(1)).toBe(1) 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /packages/easing/src/__tests__/steps.test.ts: -------------------------------------------------------------------------------- 1 | import { steps } from "../steps" 2 | 3 | test("steps", () => { 4 | const stepEnd = steps(4) 5 | 6 | expect(stepEnd(0)).toBe(0) 7 | expect(stepEnd(0.2)).toBe(0) 8 | expect(stepEnd(0.249)).toBe(0) 9 | expect(stepEnd(0.25)).toBe(0.25) 10 | expect(stepEnd(0.49)).toBe(0.25) 11 | expect(stepEnd(0.5)).toBe(0.5) 12 | expect(stepEnd(0.99)).toBe(0.75) 13 | expect(stepEnd(1)).toBe(0.75) 14 | 15 | const stepStart = steps(4, "start") 16 | expect(stepStart(0)).toBe(0.25) 17 | expect(stepStart(0.2)).toBe(0.25) 18 | expect(stepStart(0.249)).toBe(0.25) 19 | expect(stepStart(0.25)).toBe(0.25) 20 | expect(stepStart(0.49)).toBe(0.5) 21 | expect(stepStart(0.5)).toBe(0.5) 22 | expect(stepStart(0.51)).toBe(0.75) 23 | expect(stepStart(0.99)).toBe(1) 24 | expect(stepStart(1)).toBe(1) 25 | expect(stepStart(2)).toBe(1) 26 | }) 27 | -------------------------------------------------------------------------------- /packages/easing/src/cubic-bezier.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Bezier function generator 3 | 4 | This has been modified from Gaëtan Renaudeau's BezierEasing 5 | https://github.com/gre/bezier-easing/blob/master/src/index.js 6 | https://github.com/gre/bezier-easing/blob/master/LICENSE 7 | 8 | I've removed the newtonRaphsonIterate algo because in benchmarking it 9 | wasn't noticiably faster than binarySubdivision, indeed removing it 10 | usually improved times, depending on the curve. 11 | 12 | I also removed the lookup table, as for the added bundle size and loop we're 13 | only cutting ~4 or so subdivision iterations. I bumped the max iterations up 14 | to 12 to compensate and this still tended to be faster for no perceivable 15 | loss in accuracy. 16 | 17 | Usage 18 | const easeOut = cubicBezier(.17,.67,.83,.67); 19 | const x = easeOut(0.5); // returns 0.627... 20 | */ 21 | 22 | import { noopReturn } from "@motionone/utils" 23 | 24 | // Returns x(t) given t, x1, and x2, or y(t) given t, y1, and y2. 25 | const calcBezier = (t: number, a1: number, a2: number) => 26 | (((1.0 - 3.0 * a2 + 3.0 * a1) * t + (3.0 * a2 - 6.0 * a1)) * t + 3.0 * a1) * t 27 | 28 | const subdivisionPrecision = 0.0000001 29 | const subdivisionMaxIterations = 12 30 | 31 | function binarySubdivide( 32 | x: number, 33 | lowerBound: number, 34 | upperBound: number, 35 | mX1: number, 36 | mX2: number 37 | ) { 38 | let currentX: number 39 | let currentT: number 40 | let i: number = 0 41 | 42 | do { 43 | currentT = lowerBound + (upperBound - lowerBound) / 2.0 44 | currentX = calcBezier(currentT, mX1, mX2) - x 45 | if (currentX > 0.0) { 46 | upperBound = currentT 47 | } else { 48 | lowerBound = currentT 49 | } 50 | } while ( 51 | Math.abs(currentX) > subdivisionPrecision && 52 | ++i < subdivisionMaxIterations 53 | ) 54 | 55 | return currentT 56 | } 57 | 58 | export function cubicBezier( 59 | mX1: number, 60 | mY1: number, 61 | mX2: number, 62 | mY2: number 63 | ) { 64 | // If this is a linear gradient, return linear easing 65 | if (mX1 === mY1 && mX2 === mY2) return noopReturn 66 | 67 | const getTForX = (aX: number) => binarySubdivide(aX, 0, 1, mX1, mX2) 68 | 69 | // If animation is at start/end, return t without easing 70 | return (t: number) => 71 | t === 0 || t === 1 ? t : calcBezier(getTForX(t), mY1, mY2) 72 | } 73 | -------------------------------------------------------------------------------- /packages/easing/src/index.ts: -------------------------------------------------------------------------------- 1 | export { cubicBezier } from "./cubic-bezier" 2 | export { steps } from "./steps" 3 | -------------------------------------------------------------------------------- /packages/easing/src/steps.ts: -------------------------------------------------------------------------------- 1 | import type { EasingFunction } from "./types" 2 | import { clamp } from "@motionone/utils" 3 | 4 | /* 5 | Create stepped version of 0-1 progress 6 | 7 | @param [int]: Number of steps 8 | @param [number]: Current value 9 | @return [number]: Stepped value 10 | */ 11 | export type Direction = "start" | "end" 12 | 13 | export const steps = 14 | (steps: number, direction: Direction = "end"): EasingFunction => 15 | (progress: number) => { 16 | progress = 17 | direction === "end" 18 | ? Math.min(progress, 0.999) 19 | : Math.max(progress, 0.001) 20 | const expanded = progress * steps 21 | const rounded = 22 | direction === "end" ? Math.floor(expanded) : Math.ceil(expanded) 23 | 24 | return clamp(0, 1, rounded / steps) 25 | } 26 | -------------------------------------------------------------------------------- /packages/easing/src/types.ts: -------------------------------------------------------------------------------- 1 | export type EasingFunction = (t: number) => number 2 | -------------------------------------------------------------------------------- /packages/easing/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "config/ts-base.json", 3 | "$schema": "https://json.schemastore.org/tsconfig", 4 | "include": ["src/**/*.ts", "**/*.ts", "**/*.tsx"], 5 | "exclude": ["**/__tests__/*"], 6 | "compilerOptions": { 7 | "rootDir": "./src", 8 | "outDir": "./lib", 9 | "declarationDir": "./types" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/generators/.npmignore: -------------------------------------------------------------------------------- 1 | src/** 2 | .turbo 3 | tsconfig.json 4 | rollup.config.js 5 | README.md 6 | coverage/** 7 | jest.* -------------------------------------------------------------------------------- /packages/generators/README.md: -------------------------------------------------------------------------------- 1 | # `@motionone/generators` 2 | 3 | Animation generators for Motion One. 4 | 5 | ## 📚 Documentation 6 | 7 | Full docs for Motion One available at [motion.dev](https://motion.dev). 8 | -------------------------------------------------------------------------------- /packages/generators/jest.config.js: -------------------------------------------------------------------------------- 1 | const baseConfig = require("config/jest.config") 2 | 3 | /** @type {import('@jest/types').Config.InitialOptions} */ 4 | const config = { 5 | ...baseConfig, 6 | setupFilesAfterEnv: ["/../../config/jest.setup.ts"], 7 | } 8 | 9 | module.exports = config 10 | -------------------------------------------------------------------------------- /packages/generators/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@motionone/generators", 3 | "version": "10.18.0", 4 | "description": "A collection of animation generators.", 5 | "license": "MIT", 6 | "author": "Matt Perry", 7 | "main": "dist/index.cjs.js", 8 | "module": "dist/index.es.js", 9 | "types": "types/index.d.ts", 10 | "sideEffects": false, 11 | "scripts": { 12 | "build": "rimraf lib dist types && tsc -p . && rollup -c", 13 | "test": "jest --coverage --config jest.config.js", 14 | "dev": "concurrently -c blue,red -n tsc,rollup --kill-others \"tsc --watch -p . --preserveWatchOutput\" \"rollup --c --watch --no-watch.clearScreen\"", 15 | "measure": "bundlesize" 16 | }, 17 | "dependencies": { 18 | "@motionone/types": "^10.17.1", 19 | "@motionone/utils": "^10.18.0", 20 | "tslib": "^2.3.1" 21 | }, 22 | "bundlesize": [ 23 | { 24 | "path": "./dist/size-glide.js", 25 | "maxSize": "0.9 kB" 26 | }, 27 | { 28 | "path": "./dist/size-spring.js", 29 | "maxSize": "0.5 kB" 30 | } 31 | ], 32 | "gitHead": "1c67c845fb4032c9d27f3761094939b30b759f9e" 33 | } 34 | -------------------------------------------------------------------------------- /packages/generators/rollup.config.js: -------------------------------------------------------------------------------- 1 | const { createDistBuild, createSizeBuild } = require("config/rollup.config") 2 | const pkg = require("./package.json") 3 | 4 | const sizeBundles = [ 5 | ["glide/index.js", "size-glide.js"], 6 | ["spring/index.js", "size-spring.js"], 7 | ].map(([input, output]) => 8 | createSizeBuild({ input: `lib/${input}`, output: `dist/${output}` }, pkg) 9 | ) 10 | 11 | module.exports = [...createDistBuild(pkg), ...sizeBundles] 12 | -------------------------------------------------------------------------------- /packages/generators/src/glide/index.ts: -------------------------------------------------------------------------------- 1 | import { time } from "@motionone/utils" 2 | import { AnimationGenerator, AnimationGeneratorState } from "@motionone/types" 3 | import { calcGeneratorVelocity } from "../utils/velocity" 4 | import { spring as createSpring } from "../spring/index" 5 | import { GlideOptions } from "./types" 6 | 7 | export const glide = ({ 8 | from = 0, 9 | velocity = 0.0, 10 | power = 0.8, 11 | decay = 0.325, 12 | bounceDamping, 13 | bounceStiffness, 14 | changeTarget, 15 | min, 16 | max, 17 | restDistance = 0.5, 18 | restSpeed, 19 | }: GlideOptions): AnimationGenerator => { 20 | decay = time.ms(decay) 21 | 22 | const state: AnimationGeneratorState = { 23 | hasReachedTarget: false, 24 | done: false, 25 | current: from, 26 | target: from, 27 | } 28 | 29 | const isOutOfBounds = (v: number) => 30 | (min !== undefined && v < min) || (max !== undefined && v > max) 31 | 32 | const nearestBoundary = (v: number) => { 33 | if (min === undefined) return max 34 | if (max === undefined) return min 35 | 36 | return Math.abs(min - v) < Math.abs(max - v) ? min : max 37 | } 38 | 39 | let amplitude = power * velocity 40 | const ideal = from + amplitude 41 | const target = changeTarget === undefined ? ideal : changeTarget(ideal) 42 | state.target = target 43 | 44 | /** 45 | * If the target has changed we need to re-calculate the amplitude, otherwise 46 | * the animation will start from the wrong position. 47 | */ 48 | if (target !== ideal) amplitude = target - from 49 | 50 | const calcDelta = (t: number) => -amplitude * Math.exp(-t / decay) 51 | 52 | const calcLatest = (t: number) => target + calcDelta(t) 53 | 54 | const applyFriction = (t: number) => { 55 | const delta = calcDelta(t) 56 | const latest = calcLatest(t) 57 | state.done = Math.abs(delta) <= restDistance 58 | state.current = state.done ? target : latest 59 | } 60 | 61 | /** 62 | * Ideally this would resolve for t in a stateless way, we could 63 | * do that by always precalculating the animation but as we know 64 | * this will be done anyway we can assume that spring will 65 | * be discovered during that. 66 | */ 67 | let timeReachedBoundary: number | undefined 68 | let spring: AnimationGenerator | undefined 69 | 70 | const checkCatchBoundary = (t: number) => { 71 | if (!isOutOfBounds(state.current)) return 72 | 73 | timeReachedBoundary = t 74 | 75 | spring = createSpring({ 76 | from: state.current, 77 | to: nearestBoundary(state.current), 78 | velocity: calcGeneratorVelocity(calcLatest, t, state.current), // TODO: This should be passing * 1000 79 | damping: bounceDamping, 80 | stiffness: bounceStiffness, 81 | restDistance, 82 | restSpeed, 83 | }) 84 | } 85 | 86 | checkCatchBoundary(0) 87 | 88 | return (t: number) => { 89 | /** 90 | * We need to resolve the friction to figure out if we need a 91 | * spring but we don't want to do this twice per frame. So here 92 | * we flag if we updated for this frame and later if we did 93 | * we can skip doing it again. 94 | */ 95 | let hasUpdatedFrame = false 96 | if (!spring && timeReachedBoundary === undefined) { 97 | hasUpdatedFrame = true 98 | applyFriction(t) 99 | checkCatchBoundary(t) 100 | } 101 | 102 | /** 103 | * If we have a spring and the provided t is beyond the moment the friction 104 | * animation crossed the min/max boundary, use the spring. 105 | */ 106 | if (timeReachedBoundary !== undefined && t > timeReachedBoundary) { 107 | state.hasReachedTarget = true 108 | return spring!(t - timeReachedBoundary) 109 | } else { 110 | state.hasReachedTarget = false 111 | !hasUpdatedFrame && applyFriction(t) 112 | return state 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /packages/generators/src/glide/types.ts: -------------------------------------------------------------------------------- 1 | export interface GlideOptions { 2 | power?: number 3 | decay?: number 4 | changeTarget?: (v: number) => number 5 | max?: number 6 | min?: number 7 | bounceDamping?: number 8 | bounceStiffness?: number 9 | restSpeed?: number 10 | restDistance?: number 11 | from?: number 12 | to?: number 13 | velocity?: number 14 | } 15 | -------------------------------------------------------------------------------- /packages/generators/src/index.ts: -------------------------------------------------------------------------------- 1 | export { glide } from "./glide/index.js" 2 | export { spring } from "./spring/index.js" 3 | export { pregenerateKeyframes } from "./utils/pregenerate-keyframes" 4 | export { calcGeneratorVelocity } from "./utils/velocity" 5 | export type { KeyframesMetadata } from "./utils/pregenerate-keyframes" 6 | 7 | export * from "./glide/types" 8 | export * from "./spring/types" 9 | -------------------------------------------------------------------------------- /packages/generators/src/spring/defaults.ts: -------------------------------------------------------------------------------- 1 | export const defaults = { 2 | stiffness: 100.0, 3 | damping: 10.0, 4 | mass: 1.0, 5 | } 6 | -------------------------------------------------------------------------------- /packages/generators/src/spring/index.ts: -------------------------------------------------------------------------------- 1 | import { time } from "@motionone/utils" 2 | import { AnimationGenerator, AnimationGeneratorState } from "@motionone/types" 3 | import { defaults } from "./defaults" 4 | import { SpringOptions } from "./types" 5 | import { calcDampingRatio } from "./utils" 6 | import { hasReachedTarget } from "../utils/has-reached-target" 7 | import { calcGeneratorVelocity } from "../utils/velocity" 8 | 9 | export const spring = ({ 10 | stiffness = defaults.stiffness, 11 | damping = defaults.damping, 12 | mass = defaults.mass, 13 | from = 0, 14 | to = 1, 15 | velocity = 0.0, 16 | restSpeed, 17 | restDistance, 18 | }: SpringOptions = {}): AnimationGenerator => { 19 | velocity = velocity ? time.s(velocity) : 0.0 20 | 21 | const state: AnimationGeneratorState = { 22 | done: false, 23 | hasReachedTarget: false, 24 | current: from, 25 | target: to, 26 | } 27 | 28 | const initialDelta = to - from 29 | const undampedAngularFreq = Math.sqrt(stiffness / mass) / 1000 30 | const dampingRatio = calcDampingRatio(stiffness, damping, mass) 31 | 32 | const isGranularScale = Math.abs(initialDelta) < 5 33 | restSpeed ||= isGranularScale ? 0.01 : 2 34 | restDistance ||= isGranularScale ? 0.005 : 0.5 35 | 36 | let resolveSpring: (t: number) => number 37 | 38 | if (dampingRatio < 1) { 39 | const angularFreq = 40 | undampedAngularFreq * Math.sqrt(1 - dampingRatio * dampingRatio) 41 | 42 | // Underdamped spring (bouncy) 43 | resolveSpring = (t) => 44 | to - 45 | Math.exp(-dampingRatio * undampedAngularFreq * t) * 46 | (((-velocity + dampingRatio * undampedAngularFreq * initialDelta) / 47 | angularFreq) * 48 | Math.sin(angularFreq * t) + 49 | initialDelta * Math.cos(angularFreq * t)) 50 | } else { 51 | // Critically damped spring 52 | resolveSpring = (t) => { 53 | return ( 54 | to - 55 | Math.exp(-undampedAngularFreq * t) * 56 | (initialDelta + (-velocity + undampedAngularFreq * initialDelta) * t) 57 | ) 58 | } 59 | } 60 | 61 | return (t: number) => { 62 | state.current = resolveSpring(t) 63 | 64 | const currentVelocity = 65 | t === 0 66 | ? velocity 67 | : calcGeneratorVelocity(resolveSpring, t, state.current) 68 | const isBelowVelocityThreshold = Math.abs(currentVelocity) <= restSpeed! 69 | const isBelowDisplacementThreshold = 70 | Math.abs(to - state.current) <= restDistance! 71 | state.done = isBelowVelocityThreshold && isBelowDisplacementThreshold 72 | state.hasReachedTarget = hasReachedTarget(from, to, state.current) 73 | 74 | return state 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /packages/generators/src/spring/types.ts: -------------------------------------------------------------------------------- 1 | export interface SpringOptions { 2 | stiffness?: number 3 | damping?: number 4 | mass?: number 5 | restSpeed?: number 6 | restDistance?: number 7 | from?: number 8 | to?: number 9 | velocity?: number 10 | } 11 | -------------------------------------------------------------------------------- /packages/generators/src/spring/utils.ts: -------------------------------------------------------------------------------- 1 | import { defaults } from "./defaults" 2 | 3 | export const calcDampingRatio = ( 4 | stiffness = defaults.stiffness, 5 | damping = defaults.damping, 6 | mass = defaults.mass 7 | ): number => damping / (2 * Math.sqrt(stiffness * mass)) 8 | -------------------------------------------------------------------------------- /packages/generators/src/utils/__tests__/pregenerate-keyframes.test.ts: -------------------------------------------------------------------------------- 1 | import { mix } from "@motionone/utils" 2 | import { AnimationGenerator, EasingFunction } from "@motionone/types" 3 | import { hasReachedTarget } from "../has-reached-target" 4 | import { pregenerateKeyframes } from "../pregenerate-keyframes" 5 | 6 | const testGenerator = ( 7 | from: number, 8 | to: number, 9 | easing: EasingFunction 10 | ): AnimationGenerator => { 11 | return (t: number) => { 12 | const current = mix(from, to, easing(t / 100)) 13 | return { 14 | current, 15 | velocity: 100, 16 | done: t >= 100, 17 | target: to, 18 | hasReachedTarget: hasReachedTarget(from, to, current), 19 | } 20 | } 21 | } 22 | 23 | describe("pregenerateKeyframes", () => { 24 | test("it should generate keyframes from generator", () => { 25 | const data = pregenerateKeyframes(testGenerator(0, 100, (v) => v)) 26 | expect(data.keyframes).toEqual([0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]) 27 | expect(data.duration).toEqual(0.1) 28 | expect(data.overshootDuration).toEqual(0.1) 29 | }) 30 | 31 | test("it should calculate overshoot when going small -> large", () => { 32 | const double = pregenerateKeyframes(testGenerator(0, 100, (v) => v * 2)) 33 | expect(double.keyframes).toEqual([ 34 | 0, 35 | 20, 36 | 40, 37 | 60, 38 | 80, 39 | 100, 40 | 120, 41 | 140, 42 | 160, 43 | 180, 44 | 100, // Function will correctly stuff with final keyframe 45 | ]) 46 | expect(double.duration).toEqual(0.1) 47 | expect(double.overshootDuration).toEqual(0.05) 48 | }) 49 | 50 | test("it should calculate overshoot when going large -> small", () => { 51 | const double = pregenerateKeyframes(testGenerator(100, 0, (v) => v * 2)) 52 | expect(double.keyframes).toEqual([ 53 | 100, 54 | 80, 55 | 60, 56 | 40, 57 | 20, 58 | 0, 59 | -20, 60 | -40, 61 | -60, 62 | -80, 63 | 0, // Function will correctly stuff with final keyframe 64 | ]) 65 | expect(double.duration).toEqual(0.1) 66 | expect(double.overshootDuration).toEqual(0.05) 67 | }) 68 | }) 69 | -------------------------------------------------------------------------------- /packages/generators/src/utils/has-reached-target.ts: -------------------------------------------------------------------------------- 1 | export function hasReachedTarget( 2 | origin: number, 3 | target: number, 4 | current: number 5 | ) { 6 | return ( 7 | (origin < target && current >= target) || 8 | (origin > target && current <= target) 9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /packages/generators/src/utils/pregenerate-keyframes.ts: -------------------------------------------------------------------------------- 1 | import { AnimationGenerator } from "@motionone/types" 2 | import { noopReturn } from "@motionone/utils" 3 | 4 | export interface KeyframesMetadata { 5 | keyframes: Array 6 | duration: number 7 | overshootDuration: number 8 | } 9 | 10 | const timeStep = 10 11 | const maxDuration = 10000 12 | export function pregenerateKeyframes( 13 | generator: AnimationGenerator, 14 | toUnit: (value: number) => number | string = noopReturn 15 | ): KeyframesMetadata { 16 | let overshootDuration: number | undefined = undefined 17 | let timestamp = timeStep 18 | let state = generator(0) 19 | const keyframes: Array = [toUnit(state.current)] 20 | 21 | while (!state.done && timestamp < maxDuration) { 22 | state = generator(timestamp) 23 | keyframes.push(toUnit(state.done ? state.target : state.current)) 24 | 25 | if (overshootDuration === undefined && state.hasReachedTarget) { 26 | overshootDuration = timestamp 27 | } 28 | 29 | timestamp += timeStep 30 | } 31 | 32 | const duration = timestamp - timeStep 33 | 34 | /** 35 | * If generating an animation that didn't actually move, 36 | * generate a second keyframe so we have an origin and target. 37 | */ 38 | if (keyframes.length === 1) keyframes.push(state.current) 39 | 40 | return { 41 | keyframes, 42 | duration: duration / 1000, 43 | overshootDuration: (overshootDuration ?? duration) / 1000, 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/generators/src/utils/velocity.ts: -------------------------------------------------------------------------------- 1 | import { velocityPerSecond } from "@motionone/utils" 2 | 3 | const sampleT = 5 // ms 4 | export function calcGeneratorVelocity( 5 | resolveValue: (v: number) => number, 6 | t: number, 7 | current: number 8 | ) { 9 | const prevT = Math.max(t - sampleT, 0) 10 | return velocityPerSecond(current - resolveValue(prevT), t - prevT) 11 | } 12 | -------------------------------------------------------------------------------- /packages/generators/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "config/ts-base.json", 3 | "$schema": "https://json.schemastore.org/tsconfig", 4 | "include": ["src/**/*.ts", "**/*.ts", "**/*.tsx"], 5 | "exclude": ["**/__tests__/*"], 6 | "compilerOptions": { 7 | "rootDir": "./src", 8 | "outDir": "./lib", 9 | "declarationDir": "./types" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/motion/.npmignore: -------------------------------------------------------------------------------- 1 | src/** 2 | .turbo 3 | tsconfig.json 4 | rollup.config.js 5 | README.md 6 | coverage/** 7 | jest.* -------------------------------------------------------------------------------- /packages/motion/README.md: -------------------------------------------------------------------------------- 1 | Motion One logo 2 | 3 | # Motion One 4 | 5 | A new animation library, built on the Web Animations API for the smallest filesize and the fastest performance. 6 | 7 | ## 📚 Documentation 8 | 9 | Full docs are available at [motion.dev](https://motion.dev). 10 | 11 | ## 🛠 DevTools 12 | 13 | Create Motion One and CSS animations faster than ever with [Motion DevTools](https://motion.dev/tools). 14 | -------------------------------------------------------------------------------- /packages/motion/jest.config.js: -------------------------------------------------------------------------------- 1 | const baseConfig = require("config/jest.config") 2 | 3 | /** @type {import('@jest/types').Config.InitialOptions} */ 4 | const config = { 5 | ...baseConfig, 6 | setupFilesAfterEnv: ["/../../config/jest.setup.ts"], 7 | } 8 | 9 | module.exports = config 10 | -------------------------------------------------------------------------------- /packages/motion/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "motion", 3 | "description": "A tiny, performant animation library for the web", 4 | "version": "10.18.0", 5 | "license": "MIT", 6 | "author": "Matt Perry", 7 | "main": "dist/main.cjs.js", 8 | "module": "dist/main.es.js", 9 | "types": "types/index.d.ts", 10 | "keywords": [ 11 | "animation", 12 | "motion", 13 | "spring", 14 | "tween", 15 | "timeline", 16 | "dom" 17 | ], 18 | "sideEffects": false, 19 | "scripts": { 20 | "build": "rimraf lib dist types && tsc -p . && rollup -c", 21 | "test": "jest --coverage --config jest.config.js", 22 | "dev": "concurrently -c blue,red -n tsc,rollup --kill-others \"tsc --watch -p . --preserveWatchOutput\" \"rollup --c --watch --no-watch.clearScreen\"" 23 | }, 24 | "dependencies": { 25 | "@motionone/animation": "^10.18.0", 26 | "@motionone/dom": "^10.18.0", 27 | "@motionone/types": "^10.17.1", 28 | "@motionone/utils": "^10.18.0" 29 | }, 30 | "gitHead": "1c67c845fb4032c9d27f3761094939b30b759f9e" 31 | } 32 | -------------------------------------------------------------------------------- /packages/motion/rollup.config.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs") 2 | const resolve = require("@rollup/plugin-node-resolve").default 3 | const replace = require("@rollup/plugin-replace").default 4 | const { terser } = require("rollup-plugin-terser") 5 | const pkg = require("./package.json") 6 | 7 | const config = { 8 | input: "lib/index.js", 9 | } 10 | 11 | const external = [ 12 | ...Object.keys(pkg.dependencies || {}), 13 | ...Object.keys(pkg.peerDependencies || {}), 14 | ] 15 | 16 | const umd = Object.assign({}, config, { 17 | output: { 18 | file: `dist/${pkg.name}.umd.js`, 19 | format: "umd", 20 | name: "Motion", 21 | exports: "named", 22 | }, 23 | plugins: [ 24 | resolve(), 25 | replace({ 26 | preventAssignment: true, 27 | "process.env.NODE_ENV": JSON.stringify("development"), 28 | }), 29 | ], 30 | }) 31 | 32 | const umdProd = Object.assign({}, umd, { 33 | output: Object.assign({}, umd.output, { 34 | file: `dist/${pkg.name}.min.js`, 35 | }), 36 | plugins: [ 37 | resolve(), 38 | replace({ 39 | preventAssignment: true, 40 | "process.env.NODE_ENV": JSON.stringify("production"), 41 | }), 42 | terser({ output: { comments: false } }), 43 | ], 44 | }) 45 | 46 | const distEntries = { 47 | main: "lib/index.js", 48 | } 49 | 50 | const dist = { 51 | input: distEntries, 52 | output: ["cjs", "es"].map((format) => ({ 53 | dir: "dist", 54 | format, 55 | exports: "named", 56 | entryFileNames: "[name].[format].js", 57 | preserveModules: true, 58 | })), 59 | external, 60 | plugins: [ 61 | { 62 | name: "emit-proxy-package-jsons", 63 | async buildEnd() { 64 | const extraEntryNames = Object.keys(distEntries).filter( 65 | (entyrName) => entyrName !== "main" 66 | ) 67 | 68 | for (const entryName of extraEntryNames) { 69 | try { 70 | await fs.promises.mkdir(entryName) 71 | } catch (err) { 72 | if (!err || err.code !== "EEXIST") { 73 | throw err 74 | } 75 | } 76 | 77 | await fs.promises.writeFile( 78 | `${entryName}/package.json`, 79 | JSON.stringify( 80 | { 81 | private: true, 82 | main: `../dist/${entryName}.cjs.js`, 83 | module: `../dist/${entryName}.es.js`, 84 | types: `../types/${entryName}.d.ts`, 85 | }, 86 | null, 87 | 2 88 | ) + "\n" 89 | ) 90 | } 91 | }, 92 | }, 93 | ], 94 | } 95 | 96 | const createSizeBuild = ({ input, output }, plugins = []) => ({ 97 | input, 98 | output: { 99 | format: "es", 100 | exports: "named", 101 | file: output, 102 | }, 103 | plugins: [resolve(), ...plugins, terser({ output: { comments: false } })], 104 | external: [...Object.keys(pkg.peerDependencies || {})], 105 | }) 106 | 107 | const sizeAnimateDom = createSizeBuild({ 108 | input: "lib/index.js", 109 | output: "dist/size-index.js", 110 | }) 111 | 112 | module.exports = [dist, umd, umdProd, sizeAnimateDom] 113 | -------------------------------------------------------------------------------- /packages/motion/src/__tests__/animate.test.ts: -------------------------------------------------------------------------------- 1 | import { animate } from "../animate" 2 | 3 | function mockReadTime(ms: number) { 4 | jest.spyOn(window.performance, "now").mockImplementation(() => ms) 5 | } 6 | 7 | function mockTimeFrom(seconds: number) { 8 | const ms = seconds * 1000 9 | let t = ms 10 | jest.spyOn(window, "requestAnimationFrame").mockImplementation((cb: any) => { 11 | t += 50 12 | setTimeout(() => cb(t), 1) 13 | return 0 14 | }) 15 | mockReadTime(ms) 16 | } 17 | 18 | beforeEach(() => { 19 | mockTimeFrom(0) 20 | }) 21 | 22 | afterEach(() => { 23 | ;(window.requestAnimationFrame as any).mockRestore() 24 | ;(window.performance.now as any).mockRestore() 25 | }) 26 | 27 | describe("animate", () => { 28 | test("Animates numbers", async () => { 29 | const output: number[] = [] 30 | const animation = animate((p) => output.push(p)) 31 | await animation.finished 32 | expect(output).toEqual([ 33 | 0.22043358268711016, 0.5759729522294947, 0.8022760787498554, 34 | 0.9248370413624798, 0.9834614620209323, 1, 35 | ]) 36 | }) 37 | 38 | test("Doesn't add 0 frame when stop called", async () => { 39 | mockTimeFrom(0.5) 40 | const output: number[] = [] 41 | await new Promise(async (resolve) => { 42 | const animation = animate((p) => output.push(p)) 43 | await animation.finished 44 | animation.stop() 45 | resolve() 46 | }) 47 | expect(output).toEqual([ 48 | 0.22043358268711016, 0.5759729522294947, 0.8022760787498554, 49 | 0.9248370413624798, 0.9834614620209323, 1, 50 | ]) 51 | }) 52 | }) 53 | -------------------------------------------------------------------------------- /packages/motion/src/animate.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ElementOrSelector, 3 | animate as animateDom, 4 | AnimationOptionsWithOverrides, 5 | MotionKeyframesDefinition, 6 | withControls, 7 | } from "@motionone/dom" 8 | import { isFunction } from "@motionone/utils" 9 | import { Animation } from "@motionone/animation" 10 | import { 11 | AnimationControls, 12 | AnimationOptions, 13 | ProgressFunction, 14 | } from "@motionone/types" 15 | 16 | export function animateProgress( 17 | target: ProgressFunction, 18 | options: AnimationOptions = {} 19 | ) { 20 | return withControls( 21 | [ 22 | () => { 23 | const animation = new Animation(target, [0, 1], options) 24 | animation.finished.catch(() => {}) 25 | return animation 26 | }, 27 | ], 28 | options, 29 | options.duration 30 | ) 31 | } 32 | 33 | export function animate( 34 | elements: ElementOrSelector, 35 | keyframes: MotionKeyframesDefinition, 36 | options?: AnimationOptionsWithOverrides 37 | ): AnimationControls 38 | export function animate( 39 | target: ProgressFunction, 40 | options?: AnimationOptions 41 | ): AnimationControls 42 | export function animate( 43 | target: ProgressFunction | ElementOrSelector, 44 | keyframesOrOptions?: MotionKeyframesDefinition | AnimationOptions, 45 | options?: AnimationOptionsWithOverrides 46 | ): AnimationControls { 47 | const factory: any = isFunction(target) ? animateProgress : animateDom 48 | 49 | return factory(target, keyframesOrOptions, options) 50 | } 51 | -------------------------------------------------------------------------------- /packages/motion/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "@motionone/dom" 2 | export * from "@motionone/types" 3 | export { animate } from "./animate" 4 | -------------------------------------------------------------------------------- /packages/motion/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "config/ts-base.json", 3 | "$schema": "https://json.schemastore.org/tsconfig", 4 | "include": ["src/**/*.ts", "**/*.ts", "**/*.tsx"], 5 | "exclude": ["**/__tests__/*"], 6 | "compilerOptions": { 7 | "outDir": "./lib", 8 | "declarationDir": "./types" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/types/.npmignore: -------------------------------------------------------------------------------- 1 | src/** 2 | .turbo 3 | tsconfig.json 4 | rollup.config.js 5 | README.md 6 | coverage/** 7 | jest.* -------------------------------------------------------------------------------- /packages/types/README.md: -------------------------------------------------------------------------------- 1 | # `@motionone/types` 2 | 3 | Shared types for Motion One. 4 | 5 | ## 📚 Documentation 6 | 7 | Full docs for Motion One available at [motion.dev](https://motion.dev). 8 | -------------------------------------------------------------------------------- /packages/types/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@motionone/types", 3 | "version": "10.17.1", 4 | "description": "Shared types for the Motion One packages.", 5 | "license": "MIT", 6 | "author": "Matt Perry", 7 | "main": "dist/index.cjs.js", 8 | "module": "dist/index.es.js", 9 | "types": "types/index.d.ts", 10 | "sideEffects": false, 11 | "scripts": { 12 | "build": "rimraf lib dist types && tsc -p . && rollup -c", 13 | "dev": "concurrently -c blue,red -n tsc,rollup --kill-others \"tsc --watch -p . --preserveWatchOutput\" \"rollup --c --watch --no-watch.clearScreen\"" 14 | }, 15 | "gitHead": "1c67c845fb4032c9d27f3761094939b30b759f9e" 16 | } 17 | -------------------------------------------------------------------------------- /packages/types/rollup.config.js: -------------------------------------------------------------------------------- 1 | const { createDistBuild, createSizeBuild } = require("config/rollup.config") 2 | const pkg = require("./package.json") 3 | 4 | const sizeBundles = [["index.js", "size-index.js"]].map(([input, output]) => 5 | createSizeBuild({ input: `lib/${input}`, output: `dist/${output}` }, pkg) 6 | ) 7 | 8 | module.exports = [...createDistBuild(pkg), ...sizeBundles] 9 | -------------------------------------------------------------------------------- /packages/types/src/MotionValue.ts: -------------------------------------------------------------------------------- 1 | import type { AnimationGenerator, BasicAnimationControls } from "./" 2 | 3 | /** 4 | * The MotionValue tracks the state of a single animatable 5 | * value. Currently, updatedAt and current are unused. The 6 | * long term idea is to use this to minimise the number 7 | * of DOM reads, and to abstract the DOM interactions here. 8 | */ 9 | export class MotionValue { 10 | animation?: BasicAnimationControls 11 | generatorStartTime?: number 12 | generator?: AnimationGenerator 13 | 14 | setAnimation(animation?: BasicAnimationControls) { 15 | this.animation = animation 16 | 17 | animation?.finished.then(() => this.clearAnimation()).catch(() => {}) 18 | } 19 | 20 | clearAnimation() { 21 | this.animation = this.generator = undefined 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/types/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "config/ts-base.json", 3 | "$schema": "https://json.schemastore.org/tsconfig", 4 | "include": ["src/**/*.ts", "**/*.ts", "**/*.tsx"], 5 | "exclude": ["**/__tests__/*"], 6 | "compilerOptions": { 7 | "rootDir": "./src", 8 | "outDir": "./lib", 9 | "declarationDir": "./types" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/utils/.npmignore: -------------------------------------------------------------------------------- 1 | src/** 2 | .turbo 3 | tsconfig.json 4 | rollup.config.js 5 | README.md 6 | coverage/** 7 | jest.* -------------------------------------------------------------------------------- /packages/utils/README.md: -------------------------------------------------------------------------------- 1 | # `@motionone/utils` 2 | 3 | Shared utility functions for Motion One. 4 | 5 | ## 📚 Documentation 6 | 7 | Full docs for Motion One available at [motion.dev](https://motion.dev). 8 | -------------------------------------------------------------------------------- /packages/utils/jest.config.js: -------------------------------------------------------------------------------- 1 | const baseConfig = require("config/jest.config") 2 | 3 | /** @type {import('@jest/types').Config.InitialOptions} */ 4 | const config = { 5 | ...baseConfig, 6 | setupFilesAfterEnv: ["/../../config/jest.setup.ts"], 7 | } 8 | 9 | module.exports = config 10 | -------------------------------------------------------------------------------- /packages/utils/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@motionone/utils", 3 | "version": "10.18.0", 4 | "description": "A collection of utility functions for animations.", 5 | "license": "MIT", 6 | "author": "Matt Perry", 7 | "main": "dist/index.cjs.js", 8 | "module": "dist/index.es.js", 9 | "types": "types/index.d.ts", 10 | "sideEffects": false, 11 | "scripts": { 12 | "build": "rimraf lib dist types && tsc -p . && rollup -c", 13 | "test": "jest --coverage --config jest.config.js", 14 | "dev": "concurrently -c blue,red -n tsc,rollup --kill-others \"tsc --watch -p . --preserveWatchOutput\" \"rollup --c --watch --no-watch.clearScreen\"" 15 | }, 16 | "dependencies": { 17 | "@motionone/types": "^10.17.1", 18 | "hey-listen": "^1.0.8", 19 | "tslib": "^2.3.1" 20 | }, 21 | "gitHead": "1c67c845fb4032c9d27f3761094939b30b759f9e" 22 | } 23 | -------------------------------------------------------------------------------- /packages/utils/rollup.config.js: -------------------------------------------------------------------------------- 1 | const { createDistBuild, createSizeBuild } = require("config/rollup.config") 2 | const pkg = require("./package.json") 3 | 4 | module.exports = createDistBuild(pkg) 5 | -------------------------------------------------------------------------------- /packages/utils/src/__tests__/array.test.ts: -------------------------------------------------------------------------------- 1 | import { addUniqueItem } from "../array" 2 | 3 | describe("addUniqueItem", () => { 4 | test("it only adds a unique item once", () => { 5 | const array: number[] = [] 6 | addUniqueItem(array, 1) 7 | addUniqueItem(array, 2) 8 | addUniqueItem(array, 1) 9 | expect(array).toEqual([1, 2]) 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /packages/utils/src/__tests__/clamp.test.ts: -------------------------------------------------------------------------------- 1 | import { clamp } from "../clamp" 2 | 3 | test("clamp", () => { 4 | expect(clamp(100, 200, 99)).toBe(100) 5 | expect(clamp(100, 200, 201)).toBe(200) 6 | expect(clamp(100, 200, 150)).toBe(150) 7 | }) 8 | -------------------------------------------------------------------------------- /packages/utils/src/__tests__/easing.test.ts: -------------------------------------------------------------------------------- 1 | import { getEasingForSegment } from "../easing" 2 | 3 | describe("getEasingForSegment", () => { 4 | test("Returns correct easing function for the provided index", () => { 5 | expect(getEasingForSegment("ease", 2)).toEqual("ease") 6 | expect(getEasingForSegment([0, 1, 2, 3], 2)).toEqual([0, 1, 2, 3]) 7 | expect(getEasingForSegment(["ease", "linear"], 0)).toEqual("ease") 8 | expect(getEasingForSegment(["ease", "linear"], 1)).toEqual("linear") 9 | expect(getEasingForSegment(["ease", "linear"], 2)).toEqual("ease") 10 | expect(getEasingForSegment([[0, 1, 2, 3], "linear"], 2)).toEqual([ 11 | 0, 1, 2, 3, 12 | ]) 13 | expect(getEasingForSegment(["ease", "linear"], 3)).toEqual("linear") 14 | expect(getEasingForSegment(["ease", "linear", "ease-out"], 2)).toEqual( 15 | "ease-out" 16 | ) 17 | expect(getEasingForSegment(["ease", "linear", "ease-out"], 3)).toEqual( 18 | "ease" 19 | ) 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /packages/utils/src/__tests__/interpolate.test.ts: -------------------------------------------------------------------------------- 1 | import { noopReturn } from "../noop" 2 | import { interpolate } from "../interpolate" 3 | 4 | describe("interpolate", () => { 5 | test("Interpolates from one set of numbers into another", () => { 6 | expect(interpolate([0, 100])(0.5)).toEqual(50) 7 | expect(interpolate([0, 100], [0, 0.5])(-1)).toEqual(0) 8 | expect(interpolate([0, 100], [0, 0.5])(0.5)).toEqual(100) 9 | expect(interpolate([0, 100, 0])(0.75)).toEqual(50) 10 | expect(interpolate([0, 100, 0], [0.5])(0.25)).toEqual(0) 11 | expect(interpolate([0, 100, 0], [0.5])(0.75)).toEqual(100) 12 | expect(interpolate([0, 100, 500], [0.5])(2)).toEqual(500) 13 | expect(interpolate([0, 100, 500], [0.5])(-2)).toEqual(0) 14 | }) 15 | 16 | test("Applies easing", () => { 17 | expect(interpolate([0, 100, 0], undefined, noopReturn)(0.75)).toEqual(50) 18 | expect( 19 | interpolate([0, 100, 0], undefined, [noopReturn, noopReturn])(0.75) 20 | ).toEqual(50) 21 | expect(interpolate([0, 100, 0], undefined, () => 0)(0.75)).toEqual(100) 22 | expect(interpolate([0, 100, 0], undefined, () => 1)(0.75)).toEqual(0) 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /packages/utils/src/__tests__/is.test.ts: -------------------------------------------------------------------------------- 1 | import { isCubicBezier } from "../is-cubic-bezier" 2 | import { isEasingList } from "../is-easing-list" 3 | 4 | describe("isCubicBezier", () => { 5 | test("Detects a bezier array", () => { 6 | expect(isCubicBezier([0, 1, 2, 3])).toEqual(true) 7 | expect(isCubicBezier(["steps(5, start)", [0, 1, 2, 3], "linear"])).toEqual( 8 | false 9 | ) 10 | }) 11 | }) 12 | 13 | describe("isEasingList", () => { 14 | test("Detects an easing list", () => { 15 | expect(isEasingList([0, 1, 2, 3])).toEqual(false) 16 | expect(isEasingList(["steps(5, start)", [0, 1, 2, 3], "linear"])).toEqual( 17 | true 18 | ) 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /packages/utils/src/__tests__/mix.test.ts: -------------------------------------------------------------------------------- 1 | import { mix } from "../mix" 2 | 3 | test("mix", () => { 4 | expect(mix(0, 1, 0.5)).toBe(0.5) 5 | expect(mix(-100, 100, 2)).toBe(300) 6 | expect(mix(10, 20, 0.5)).toBe(15) 7 | expect(mix(-10, -20, 0.5)).toBe(-15) 8 | expect(mix(0, 80, 0.5)).toBe(40) 9 | expect(mix(100, 200, 2)).toBe(300) 10 | expect(mix(-100, 100, 2)).toBe(300) 11 | }) 12 | -------------------------------------------------------------------------------- /packages/utils/src/__tests__/offset.test.ts: -------------------------------------------------------------------------------- 1 | import { defaultOffset, fillOffset } from "../offset" 2 | 3 | describe("defaultOffset", () => { 4 | test("Fills an array of length with evenly spaced progress values", () => { 5 | expect(defaultOffset(1)).toEqual([0]) 6 | expect(defaultOffset(2)).toEqual([0, 1]) 7 | expect(defaultOffset(3)).toEqual([0, 0.5, 1]) 8 | expect(defaultOffset(5)).toEqual([0, 0.25, 0.5, 0.75, 1]) 9 | }) 10 | }) 11 | 12 | describe("fillOffset", () => { 13 | test("Fills an array from the provided min to 1", () => { 14 | const a = [0, 0.4] 15 | fillOffset(a, 1) 16 | const b = [0, 0.4] 17 | fillOffset(b, 2) 18 | const c = [0, 0.4] 19 | fillOffset(c, 3) 20 | 21 | expect(a).toEqual([0, 0.4, 1]) 22 | expect(b).toEqual([0, 0.4, 0.7, 1]) 23 | expect(c).toEqual([0, 0.4, 0.6, 0.8, 1]) 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /packages/utils/src/__tests__/progress.test.ts: -------------------------------------------------------------------------------- 1 | import { progress } from "../progress" 2 | 3 | test("progress", () => { 4 | expect(progress(0, 100, 50)).toBe(0.5) 5 | expect(progress(100, -100, 50)).toBe(0.25) 6 | expect(progress(100, -100, -300)).toBe(2) 7 | expect(progress(0, 100, 100)).toBe(1) 8 | }) 9 | -------------------------------------------------------------------------------- /packages/utils/src/__tests__/time.test.ts: -------------------------------------------------------------------------------- 1 | import { time } from "../time" 2 | 3 | describe("ms", () => { 4 | test("Expresses seconds as milliseconds", () => { 5 | expect(time.ms(1)).toBe(1000) 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /packages/utils/src/__tests__/velocity.test.ts: -------------------------------------------------------------------------------- 1 | import { velocityPerSecond } from "../velocity" 2 | 3 | test("velocityPerSecond", () => { 4 | expect(velocityPerSecond(0.835, 16.7)).toBe(50) 5 | expect(velocityPerSecond(0.835, 0)).toBe(0) 6 | }) 7 | -------------------------------------------------------------------------------- /packages/utils/src/__tests__/wrap.test.ts: -------------------------------------------------------------------------------- 1 | import { wrap } from "../wrap" 2 | 3 | test("wrap", () => { 4 | expect(wrap(-100, 100, -100)).toBe(-100) 5 | expect(wrap(-100, 100, 0)).toBe(0) 6 | expect(wrap(-100, 100, -200)).toBe(0) 7 | expect(wrap(-100, 100, 101)).toBe(-99) 8 | }) 9 | -------------------------------------------------------------------------------- /packages/utils/src/array.ts: -------------------------------------------------------------------------------- 1 | export function addUniqueItem(array: T[], item: T) { 2 | array.indexOf(item) === -1 && array.push(item) 3 | } 4 | 5 | export function removeItem(arr: T[], item: T) { 6 | const index = arr.indexOf(item) 7 | index > -1 && arr.splice(index, 1) 8 | } 9 | -------------------------------------------------------------------------------- /packages/utils/src/clamp.ts: -------------------------------------------------------------------------------- 1 | export const clamp = (min: number, max: number, v: number) => 2 | Math.min(Math.max(v, min), max) 3 | -------------------------------------------------------------------------------- /packages/utils/src/defaults.ts: -------------------------------------------------------------------------------- 1 | import { Easing } from "@motionone/types" 2 | 3 | export const defaults = { 4 | duration: 0.3, 5 | delay: 0, 6 | endDelay: 0, 7 | repeat: 0, 8 | easing: "ease" as Easing, 9 | } 10 | -------------------------------------------------------------------------------- /packages/utils/src/easing.ts: -------------------------------------------------------------------------------- 1 | import { Easing, EasingFunction } from "@motionone/types" 2 | import { isEasingList } from "./is-easing-list" 3 | import { wrap } from "./wrap" 4 | 5 | export function getEasingForSegment( 6 | easing: Easing | Easing[], 7 | i: number 8 | ): Easing 9 | export function getEasingForSegment( 10 | easing: EasingFunction | EasingFunction[], 11 | i: number 12 | ): EasingFunction 13 | export function getEasingForSegment( 14 | easing: Easing | Easing[] | EasingFunction | EasingFunction[], 15 | i: number 16 | ) { 17 | return isEasingList(easing) ? easing[wrap(0, easing.length, i)] : easing 18 | } 19 | -------------------------------------------------------------------------------- /packages/utils/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./array" 2 | export * from "./clamp" 3 | export * from "./defaults" 4 | export * from "./easing" 5 | export * from "./interpolate" 6 | export * from "./is-cubic-bezier" 7 | export * from "./is-easing-generator" 8 | export * from "./is-easing-list" 9 | export * from "./is-function" 10 | export * from "./is-number" 11 | export * from "./is-string" 12 | export * from "./mix" 13 | export * from "./noop" 14 | export * from "./offset" 15 | export * from "./progress" 16 | export * from "./time" 17 | export * from "./velocity" 18 | export * from "./wrap" 19 | -------------------------------------------------------------------------------- /packages/utils/src/interpolate.ts: -------------------------------------------------------------------------------- 1 | import { mix } from "./mix" 2 | import { noopReturn } from "./noop" 3 | import { defaultOffset, fillOffset } from "./offset" 4 | import { progress } from "./progress" 5 | import { getEasingForSegment } from "./easing" 6 | import type { EasingFunction } from "@motionone/types" 7 | import { clamp } from "./clamp" 8 | 9 | export function interpolate( 10 | output: number[], 11 | input: number[] = defaultOffset(output.length), 12 | easing: EasingFunction | EasingFunction[] = noopReturn 13 | ) { 14 | const length = output.length 15 | 16 | /** 17 | * If the input length is lower than the output we 18 | * fill the input to match. This currently assumes the input 19 | * is an animation progress value so is a good candidate for 20 | * moving outside the function. 21 | */ 22 | const remainder = length - input.length 23 | remainder > 0 && fillOffset(input, remainder) 24 | 25 | return (t: number) => { 26 | let i = 0 27 | for (; i < length - 2; i++) { 28 | if (t < input[i + 1]) break 29 | } 30 | 31 | let progressInRange = clamp(0, 1, progress(input[i], input[i + 1], t)) 32 | const segmentEasing = getEasingForSegment(easing, i) 33 | progressInRange = segmentEasing(progressInRange) 34 | 35 | return mix(output[i], output[i + 1], progressInRange) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/utils/src/is-cubic-bezier.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BezierDefinition, 3 | Easing, 4 | EasingFunction, 5 | EasingGenerator, 6 | } from "@motionone/types" 7 | import { isNumber } from "./is-number" 8 | 9 | export const isCubicBezier = ( 10 | easing: EasingGenerator | Easing | Easing[] | EasingFunction 11 | ): easing is BezierDefinition => Array.isArray(easing) && isNumber(easing[0]) 12 | -------------------------------------------------------------------------------- /packages/utils/src/is-easing-generator.ts: -------------------------------------------------------------------------------- 1 | import { Easing, EasingFunction, EasingGenerator } from "@motionone/types" 2 | 3 | export const isEasingGenerator = ( 4 | easing: Easing | Easing[] | EasingGenerator | EasingFunction 5 | ): easing is EasingGenerator => 6 | typeof easing === "object" && 7 | Boolean((easing as EasingGenerator).createAnimation) 8 | -------------------------------------------------------------------------------- /packages/utils/src/is-easing-list.ts: -------------------------------------------------------------------------------- 1 | import { Easing, EasingFunction, EasingGenerator } from "@motionone/types" 2 | import { isNumber } from "./is-number" 3 | 4 | export const isEasingList = ( 5 | easing: 6 | | EasingGenerator 7 | | Easing 8 | | Easing[] 9 | | undefined 10 | | EasingFunction 11 | | EasingFunction[] 12 | ): easing is Easing[] => Array.isArray(easing) && !isNumber(easing[0]) 13 | -------------------------------------------------------------------------------- /packages/utils/src/is-function.ts: -------------------------------------------------------------------------------- 1 | export const isFunction = (value: unknown): value is Function => 2 | typeof value === "function" 3 | -------------------------------------------------------------------------------- /packages/utils/src/is-number.ts: -------------------------------------------------------------------------------- 1 | export const isNumber = (value: unknown): value is number => 2 | typeof value === "number" 3 | -------------------------------------------------------------------------------- /packages/utils/src/is-string.ts: -------------------------------------------------------------------------------- 1 | export const isString = (value: unknown): value is string => 2 | typeof value === "string" 3 | -------------------------------------------------------------------------------- /packages/utils/src/mix.ts: -------------------------------------------------------------------------------- 1 | export const mix = (min: number, max: number, progress: number) => 2 | -progress * min + progress * max + min 3 | -------------------------------------------------------------------------------- /packages/utils/src/noop.ts: -------------------------------------------------------------------------------- 1 | export const noop = () => {} 2 | export const noopReturn = (v: V) => v 3 | -------------------------------------------------------------------------------- /packages/utils/src/offset.ts: -------------------------------------------------------------------------------- 1 | import { mix } from "./mix" 2 | import { progress } from "./progress" 3 | 4 | export function fillOffset(offset: number[], remaining: number): void { 5 | const min = offset[offset.length - 1] 6 | for (let i = 1; i <= remaining; i++) { 7 | const offsetProgress = progress(0, remaining, i) 8 | offset.push(mix(min, 1, offsetProgress)) 9 | } 10 | } 11 | 12 | export function defaultOffset(length: number): number[] { 13 | const offset = [0] 14 | fillOffset(offset, length - 1) 15 | return offset 16 | } 17 | -------------------------------------------------------------------------------- /packages/utils/src/progress.ts: -------------------------------------------------------------------------------- 1 | export const progress = (min: number, max: number, value: number) => 2 | max - min === 0 ? 1 : (value - min) / (max - min) 3 | -------------------------------------------------------------------------------- /packages/utils/src/time.ts: -------------------------------------------------------------------------------- 1 | export const time = { 2 | ms: (seconds: number) => seconds * 1000, 3 | s: (milliseconds: number) => milliseconds / 1000, 4 | } 5 | -------------------------------------------------------------------------------- /packages/utils/src/velocity.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Convert velocity into velocity per second 3 | 4 | @param [number]: Unit per frame 5 | @param [number]: Frame duration in ms 6 | */ 7 | export function velocityPerSecond(velocity: number, frameDuration: number) { 8 | return frameDuration ? velocity * (1000 / frameDuration) : 0 9 | } 10 | -------------------------------------------------------------------------------- /packages/utils/src/wrap.ts: -------------------------------------------------------------------------------- 1 | export const wrap = (min: number, max: number, v: number) => { 2 | const rangeSize = max - min 3 | return ((((v - min) % rangeSize) + rangeSize) % rangeSize) + min 4 | } 5 | -------------------------------------------------------------------------------- /packages/utils/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "config/ts-base.json", 3 | "$schema": "https://json.schemastore.org/tsconfig", 4 | "include": ["src/**/*.ts", "**/*.ts", "**/*.tsx"], 5 | "exclude": ["**/__tests__/*"], 6 | "compilerOptions": { 7 | "rootDir": "./src", 8 | "outDir": "./lib", 9 | "declarationDir": "./types" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./packages/config/ts-base.json" 3 | } 4 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "npmClient": "yarn", 3 | "baseBranch": "origin/main", 4 | "pipeline": { 5 | "build": { 6 | "dependsOn": ["^build"], 7 | "outputs": ["types/**", "lib/**", "dist/**", ".next/**"] 8 | }, 9 | "lint": { 10 | "outputs": [] 11 | }, 12 | "test": { 13 | "dependsOn": ["build"], 14 | "outputs": [] 15 | }, 16 | "deploy": { 17 | "dependsOn": ["build", "test", "lint"] 18 | }, 19 | "measure": { 20 | "dependsOn": ["build"] 21 | }, 22 | "publish": { 23 | "dependsOn": ["build", "test", "lint"] 24 | }, 25 | "dev": { 26 | "outputs": ["dist/**", ".next/**"], 27 | "cache": false 28 | } 29 | } 30 | } 31 | --------------------------------------------------------------------------------