├── .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 |
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 |
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 |
--------------------------------------------------------------------------------