├── .eslintignore
├── .eslintrc.json
├── .github
└── pull_request_template.md
├── .gitignore
├── .prettierrc
├── .yarnrc.yml
├── LICENSE
├── README.md
├── docs
├── mdx-components.js
├── next-env.d.ts
├── next.config.mjs
├── package.json
├── src
│ ├── app
│ │ ├── GlobalStylings.tsx
│ │ ├── _meta.ts
│ │ ├── docs
│ │ │ └── [[...mdxPath]]
│ │ │ │ └── page.tsx
│ │ ├── layout.tsx
│ │ ├── opengraph-image.png
│ │ └── page.tsx
│ └── content
│ │ ├── _meta.ts
│ │ └── index.mdx
└── tsconfig.json
├── mobxmotion
├── .npmignore
├── README.md
├── demo
│ ├── Demo.tsx
│ ├── app.tsx
│ ├── index.html
│ └── index.tsx
├── package.json
├── src
│ ├── AnimatedSpring.ts
│ ├── MobxMotion.tsx
│ ├── Spring.ts
│ ├── components.tsx
│ ├── hooks.ts
│ ├── index.ts
│ ├── springConfig.ts
│ ├── springStep.ts
│ ├── springs.ts
│ ├── style.ts
│ └── utils.ts
├── tsconfig.build.json
├── tsconfig.json
├── vite.config.build.ts
├── vite.config.demo.ts
└── vitest.config.ts
├── package.json
├── vercel.json
└── yarn.lock
/.eslintignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | dist/
3 | .prettierrc.ts
4 | .eslintrc.json
5 | env.d.ts
6 | vite.config.ts
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | // By extending from a plugin config, we can get recommended rules without having to add them manually.
4 | "eslint:recommended",
5 | "plugin:import/recommended",
6 | "plugin:@typescript-eslint/recommended",
7 | // This disables the formatting rules in ESLint that Prettier is going to be responsible for handling.
8 | // Make sure it's always the last config, so it gets the chance to override other configs.
9 | "eslint-config-prettier"
10 | ],
11 |
12 | "settings": {
13 | // Tells eslint how to resolve imports
14 | "import/resolver": {
15 | "node": {
16 | "paths": ["src"],
17 | "extensions": [".js", ".jsx", ".ts", ".tsx"]
18 | }
19 | }
20 | },
21 | "parserOptions": {
22 | "sourceType": "module"
23 | },
24 | "rules": {
25 | "no-console" : 2
26 | }
27 | }
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 | # Pull request template
2 |
3 | ***REMOVE THIS - START***
4 |
5 | 1. Title should be something descriptive about what you're changes do. (It will default to whatever you put as your commit message.)
6 | 2. Make sure to point to `dev` branch;
7 | 3. Mark your pull request as `DRAFT` until it will be ready to review;
8 | 4. Before marking a PR ready to review, make sure that:
9 |
10 | a. You have done your changes in a separate branch. Branches MUST have descriptive names that start with either the `fix/` or `feature/` prefixes. Good examples are: `fix/signin-issue` or `feature/issue-templates`.
11 |
12 | b. `npm test` doesn't throw any error.
13 |
14 | 5. Describe your changes, link to the issue and add images if relevant under each #TODO next comments;
15 | 6. MAKE SURE TO CLEAN ALL THIS 6 POINTS BEFORE SUBMITTING
16 |
17 | ***REMOVE THIS - END***
18 |
19 | ## Describe your changes
20 | TODO: Add a short summary of your changes and impact:
21 |
22 | ## Link to issue this resolves
23 | TODO: Add your issue no. or link to the issue. Example:
24 | Fixes: #100
25 |
26 | ## Screenshot of changes(if relevant)
27 | TODO: Add images:
28 |
29 | 🙏🙏 !! THANK YOU !! 🚀🚀
30 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
26 | .yarn/*
27 |
28 | .next/
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "trailingComma": "all",
3 | "tabWidth": 2,
4 | "semi": true,
5 | "singleQuote": false,
6 | "printWidth": 120,
7 | "bracketSpacing": true
8 | }
9 |
--------------------------------------------------------------------------------
/.yarnrc.yml:
--------------------------------------------------------------------------------
1 | nodeLinker: node-modules
2 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Adam Pietrasiak
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 | # mobxmotion
2 |
3 | `mobxmotion` allows you to animate elements using `mobx` without re-rendering the parent component.
4 |
5 | `mobxmotion.div` is a drop-in replacement for a normal `div`, except its `style` prop can include getters.
6 |
7 | If a getter is present, the given style value will be re-evaluated and updated when mobx detects a change, but the parent component will not re-render.
8 |
9 | ```bash
10 | yarn add mobxmotion
11 | # or
12 | npm install mobxmotion
13 | # or
14 | pnpm add mobxmotion
15 | ```
16 |
17 | ## Example
18 |
19 | Let's say we have some observable `x` value and we want to use it to set the `transform` style of a `div`.
20 |
21 | ```tsx
22 | import { mobxmotion } from "mobxmotion";
23 | import { observable } from "mobx";
24 |
25 | const x = observable.box(0);
26 |
27 | export function Demo() {
28 | useSomeHeavyComputation(); // Render is expensive
29 |
30 | return (
31 |
40 |
47 |
48 | );
49 | }
50 | ```
51 |
52 | When observable `x` changes, `style.transform` will be re-evaluated and updated, but the component itself will not re-render.
53 |
54 | ## Example with spring animation
55 |
56 | In the example above, when `x` changes, the component will instantly move to the new position.
57 |
58 | We can easily use spring animation to make the movement smooth.
59 |
60 | The only change we need to make is to pass `x` value through `$spring` function inside the getter.
61 |
62 | Change this:
63 |
64 | ```tsx
65 | style={{
66 | get transform() {
67 | const transformX = x.get();
68 |
69 | return `translate(${transformX}px, 0)`;
70 | },
71 | }}
72 | ```
73 |
74 | To this:
75 |
76 | ```tsx
77 | style={{
78 | get transform() {
79 | const transformX = $spring(x.get());
80 |
81 | return `translate(${transformX}px, 0)`;
82 | },
83 | }}
84 | ```
85 |
86 | And that's it! Now when `x` changes, transform will be animated to the new value. Component will not re-render during the animation.
87 |
88 | The full example with spring animation:
89 |
90 | ```tsx
91 | import { mobxmotion, $spring } from "mobxmotion";
92 | import { observable } from "mobx";
93 |
94 | const x = observable.box(0);
95 |
96 | export function Demo() {
97 | return (
98 |
107 |
114 |
115 | );
116 | }
117 | ```
118 |
119 | `$spring` also accepts 2nd argument, `springConfig` which allows you to customize the spring animation.
120 |
121 | ```tsx
122 | const SPRING_CONFIG: SpringConfigInput = {
123 | stiffness: 100,
124 | damping: 10,
125 | mass: 1,
126 | };
127 |
128 | // Inside the getter:
129 | const transformX = $spring(x.get(), SPRING_CONFIG);
130 | ```
131 |
132 | It is recommended to define spring config outside of the getter to make it faster for the animator to determine if the config needs to be updated.
133 |
134 | > [!NOTE]
135 | >
136 | > `$spring` calls inside getters have to follow same rules as React hooks.
137 | >
138 | > They cannot be called conditionally, they have to be called on every getter call. You can use multiple `$spring` calls in one getter, but you have to call it the same number of times in every getter call.
139 |
140 | ---
141 |
142 | All the other props are exactly the same as in normal `div`.
143 |
144 | You can still use regular styles, without getters, as you would do with a normal `div`.
145 |
146 | Every existing `div` can be replaced with `mobxmotion.div` without any changes to the code.
147 |
148 | You can also use `mobxmotion.p`, `mobxmotion.span`, `mobxmotion.button`, etc. for every `HTML` and `SVG` element.
149 |
150 | ## License
151 |
152 | MIT License
153 |
154 | Copyright (c) 2024 Adam Pietrasiak
155 |
156 | Permission is hereby granted, free of charge, to any person obtaining a copy
157 | of this software and associated documentation files (the "Software"), to deal
158 | in the Software without restriction, including without limitation the rights
159 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
160 | copies of the Software, and to permit persons to whom the Software is
161 | furnished to do so, subject to the following conditions:
162 |
163 | The above copyright notice and this permission notice shall be included in all
164 | copies or substantial portions of the Software.
165 |
166 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
167 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
168 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
169 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
170 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
171 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
172 | SOFTWARE.
173 |
--------------------------------------------------------------------------------
/docs/mdx-components.js:
--------------------------------------------------------------------------------
1 | import { useMDXComponents as getDocsMDXComponents } from 'nextra-theme-docs'
2 |
3 | const docsComponents = getDocsMDXComponents()
4 |
5 | export const useMDXComponents = components => ({
6 | ...docsComponents,
7 | ...components
8 | })
9 |
--------------------------------------------------------------------------------
/docs/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
6 |
--------------------------------------------------------------------------------
/docs/next.config.mjs:
--------------------------------------------------------------------------------
1 | import nextra from "nextra";
2 |
3 | const withNextra = nextra({
4 | latex: true,
5 | search: {
6 | codeblocks: false,
7 | },
8 | contentDirBasePath: "/docs",
9 | });
10 |
11 | export default withNextra({
12 | reactStrictMode: true,
13 | typescript: {
14 | ignoreBuildErrors: true,
15 | },
16 | eslint: {
17 | ignoreDuringBuilds: true,
18 | },
19 | });
20 |
--------------------------------------------------------------------------------
/docs/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "mobxmotion-docs",
3 | "license": "MIT",
4 | "private": true,
5 | "scripts": {
6 | "build": "next build",
7 | "dev": "next --turbopack",
8 | "postbuild": "pagefind --site .next/server/app --output-path public/_pagefind",
9 | "start": "next start"
10 | },
11 | "dependencies": {
12 | "next": "^15.0.2",
13 | "nextra": "^4.2.17",
14 | "nextra-theme-docs": "^4.2.17",
15 | "react": "18.3.1",
16 | "react-dom": "18.3.1"
17 | },
18 | "devDependencies": {
19 | "pagefind": "^1.3.0"
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/docs/src/app/GlobalStylings.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect } from "react";
4 |
5 | export function GlobalStylings() {
6 | useEffect(() => {
7 | // Reflect.set(window, "mobxmotion", mobxmotion);
8 | });
9 | return null;
10 | }
11 |
--------------------------------------------------------------------------------
/docs/src/app/_meta.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | index: {
3 | display: "hidden",
4 | },
5 | docs: {
6 | type: "page",
7 | title: "Documentation",
8 | },
9 | };
10 |
--------------------------------------------------------------------------------
/docs/src/app/docs/[[...mdxPath]]/page.tsx:
--------------------------------------------------------------------------------
1 | import { generateStaticParamsFor, importPage } from 'nextra/pages'
2 | import { useMDXComponents as getMDXComponents } from '../../../../mdx-components'
3 |
4 | export const generateStaticParams = generateStaticParamsFor('mdxPath')
5 |
6 | export async function generateMetadata(props) {
7 | const params = await props.params
8 | const { metadata } = await importPage(params.mdxPath)
9 | return metadata
10 | }
11 |
12 | const Wrapper = getMDXComponents().wrapper
13 |
14 | export default async function Page(props) {
15 | const params = await props.params
16 | const result = await importPage(params.mdxPath)
17 | const { default: MDXContent, toc, metadata } = result
18 | return (
19 |
20 |
21 |
22 | )
23 | }
24 |
--------------------------------------------------------------------------------
/docs/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import "nextra-theme-docs/style.css";
2 |
3 | import { Banner, Head } from "nextra/components";
4 | /* eslint-env node */
5 | import { Footer, Layout, Navbar } from "nextra-theme-docs";
6 |
7 | import { GlobalStylings } from "./GlobalStylings";
8 | import { getPageMap } from "nextra/page-map";
9 |
10 | export const metadata = {
11 | metadataBase: new URL("https://mobxmotion.com"),
12 | title: {
13 | template: "%s - mobxmotion",
14 | },
15 | description: "mobxmotion: joyful styling for React and Styled Components",
16 | applicationName: "mobxmotion",
17 | generator: "Next.js",
18 | appleWebApp: {
19 | title: "mobxmotion",
20 | },
21 | // other: {
22 | // "msapplication-TileImage": "/ms-icon-144x144.png",
23 | // "msapplication-TileColor": "#fff",
24 | // },
25 | twitter: {
26 | site: "https://mobxmotion.com",
27 | card: "summary_large_image",
28 | },
29 | openGraph: {
30 | type: "website",
31 | url: "https://mobxmotion.com",
32 | title: "mobxmotion",
33 | description: "mobxmotion: joyful styling for React and Styled Components",
34 | images: [
35 | {
36 | url: "https://mobxmotion.com/opengraph-image.png",
37 | width: 1200,
38 | height: 630,
39 | alt: "mobxmotion",
40 | },
41 | ],
42 | },
43 | };
44 |
45 | export default async function RootLayout({ children }) {
46 | const navbar = (
47 |
50 | mobxmotion
51 |
52 | }
53 | projectLink="https://github.com/pie6k/mobxmotion"
54 | />
55 | );
56 | const pageMap = await getPageMap();
57 | return (
58 |
59 |
60 |
61 | Nextra 2 Alpha}
63 | navbar={navbar}
64 | footer={}
65 | editLink="Edit this page on GitHub"
66 | docsRepositoryBase="https://github.com/pie6k/mobxmotion/blob/main/docs"
67 | sidebar={{ defaultMenuCollapseLevel: 1 }}
68 | pageMap={pageMap}
69 | >
70 |
71 | {children}
72 |
73 |
74 |
75 | );
76 | }
77 |
--------------------------------------------------------------------------------
/docs/src/app/opengraph-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pie6k/mobxmotion/de1f51b9790cb1a09c814c58388e405a0a9cfa89/docs/src/app/opengraph-image.png
--------------------------------------------------------------------------------
/docs/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from "next/navigation";
2 |
3 | export default async function IndexPage() {
4 | redirect("/docs");
5 | }
6 |
--------------------------------------------------------------------------------
/docs/src/content/_meta.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | index: "",
3 | };
4 |
--------------------------------------------------------------------------------
/docs/src/content/index.mdx:
--------------------------------------------------------------------------------
1 | # Quick Start
2 |
3 | `mobxmotion` allows you to animate elements using `mobx` without re-rendering the parent component.
4 |
5 | `mobxmotion.div` is a drop-in replacement for a normal `div`, except its `style` prop can include getters.
6 |
7 | If a getter is present, the given style value will be re-evaluated and updated when mobx detects a change, but the parent component will not re-render.
8 |
9 | ```bash
10 | yarn add mobxmotion
11 | # or
12 | npm install mobxmotion
13 | # or
14 | pnpm add mobxmotion
15 | ```
16 |
17 | ## Example
18 |
19 | Let's say we have some observable `x` value and we want to use it to set the `transform` style of a `div`.
20 |
21 | ```tsx
22 | import { mobxmotion } from "mobxmotion";
23 | import { observable } from "mobx";
24 |
25 | const x = observable.box(0);
26 |
27 | export function Demo() {
28 | useSomeHeavyComputation(); // Render is expensive
29 |
30 | return (
31 |
40 |
47 |
48 | );
49 | }
50 | ```
51 |
52 | When observable `x` changes, `style.transform` will be re-evaluated and updated, but the component itself will not re-render.
53 |
54 | ## Example with spring animation
55 |
56 | In the example above, when `x` changes, the component will instantly move to the new position.
57 |
58 | We can easily use spring animation to make the movement smooth.
59 |
60 | The only change we need to make is to pass `x` value through `$spring` function inside the getter.
61 |
62 | Change this:
63 |
64 | ```tsx
65 | style={{
66 | get transform() {
67 | const transformX = x.get();
68 |
69 | return `translate(${transformX}px, 0)`;
70 | },
71 | }}
72 | ```
73 |
74 | To this:
75 |
76 | ```tsx
77 | style={{
78 | get transform() {
79 | const transformX = $spring(x.get());
80 |
81 | return `translate(${transformX}px, 0)`;
82 | },
83 | }}
84 | ```
85 |
86 | And that's it! Now when `x` changes, transform will be animated to the new value. Component will not re-render during the animation.
87 |
88 | The full example with spring animation:
89 |
90 | ```tsx
91 | import { mobxmotion, $spring } from "mobxmotion";
92 | import { observable } from "mobx";
93 |
94 | const x = observable.box(0);
95 |
96 | export function Demo() {
97 | return (
98 |
107 |
114 |
115 | );
116 | }
117 | ```
118 |
119 | `$spring` also accepts 2nd argument, `springConfig` which allows you to customize the spring animation.
120 |
121 | ```tsx
122 | const SPRING_CONFIG: SpringConfigInput = {
123 | stiffness: 100,
124 | damping: 10,
125 | mass: 1,
126 | };
127 |
128 | // Inside the getter:
129 | const transformX = $spring(x.get(), SPRING_CONFIG);
130 | ```
131 |
132 | It is recommended to define spring config outside of the getter to make it faster for the animator to determine if the config needs to be updated.
133 |
134 | > [!NOTE]
135 | >
136 | > `$spring` calls inside getters have to follow same rules as React hooks.
137 | >
138 | > They cannot be called conditionally, they have to be called on every getter call. You can use multiple `$spring` calls in one getter, but you have to call it the same number of times in every getter call.
139 |
140 | ---
141 |
142 | All the other props are exactly the same as in normal `div`.
143 |
144 | You can still use regular styles, without getters, as you would do with a normal `div`.
145 |
146 | Every existing `div` can be replaced with `mobxmotion.div` without any changes to the code.
147 |
148 | You can also use `mobxmotion.p`, `mobxmotion.span`, `mobxmotion.button`, etc. for every `HTML` and `SVG` element.
149 |
150 | ## License
151 |
152 | MIT License
153 |
154 | Copyright (c) 2024 Adam Pietrasiak
155 |
156 | Permission is hereby granted, free of charge, to any person obtaining a copy
157 | of this software and associated documentation files (the "Software"), to deal
158 | in the Software without restriction, including without limitation the rights
159 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
160 | copies of the Software, and to permit persons to whom the Software is
161 | furnished to do so, subject to the following conditions:
162 |
163 | The above copyright notice and this permission notice shall be included in all
164 | copies or substantial portions of the Software.
165 |
166 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
167 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
168 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
169 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
170 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
171 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
172 | SOFTWARE.
173 |
--------------------------------------------------------------------------------
/docs/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "strict": false,
12 | "noEmit": true,
13 | "incremental": true,
14 | "module": "esnext",
15 | "esModuleInterop": true,
16 | "moduleResolution": "node",
17 | "resolveJsonModule": true,
18 | "isolatedModules": true,
19 | "jsx": "preserve",
20 | "plugins": [
21 | {
22 | "name": "next"
23 | }
24 | ]
25 | },
26 | "include": [
27 | "next-env.d.ts",
28 | ".next/types/**/*.ts",
29 | "**/*.ts",
30 | "**/*.tsx"
31 | ],
32 | "exclude": [
33 | "node_modules"
34 | ]
35 | }
36 |
--------------------------------------------------------------------------------
/mobxmotion/.npmignore:
--------------------------------------------------------------------------------
1 | # Source files
2 | src/
3 | *.ts
4 | *.tsx
5 |
6 | # Development configs
7 | .vscode/
8 | .idea/
9 | *.config.ts
10 | .eslintrc*
11 | .prettierrc*
12 | .editorconfig
13 | .gitignore
14 | .npmrc
15 | .yarnrc*
16 | .yarn/
17 |
18 | # Test files
19 | __tests__/
20 | *.test.ts
21 | *.test.tsx
22 | coverage/
23 | .nyc_output/
24 |
25 | # Development scripts
26 | scripts/
27 | demo/
28 | examples/
29 |
30 | # Build tools
31 | vite.config.*
32 | tsconfig.json
33 | jest.config.*
34 | vitest.config.*
35 |
36 | # Documentation
37 | docs/
38 | *.md
39 | !README.md
40 | !LICENSE
41 |
42 | # Misc
43 | .DS_Store
44 | *.log
45 | .env*
46 | .cache/
47 | .temp/
48 | .tmp/
--------------------------------------------------------------------------------
/mobxmotion/README.md:
--------------------------------------------------------------------------------
1 | # mobxmotion
2 |
3 | `mobxmotion` allows you to animate elements using `mobx` without re-rendering the parent component.
4 |
5 | `mobxmotion.div` is a drop-in replacement for a normal `div`, except its `style` prop can include getters.
6 |
7 | If a getter is present, the given style value will be re-evaluated and updated when mobx detects a change, but the parent component will not re-render.
8 |
9 | ```bash
10 | yarn add mobxmotion
11 | # or
12 | npm install mobxmotion
13 | # or
14 | pnpm add mobxmotion
15 | ```
16 |
17 | ## Example
18 |
19 | Let's say we have some observable `x` value and we want to use it to set the `transform` style of a `div`.
20 |
21 | ```tsx
22 | import { mobxmotion } from "mobxmotion";
23 | import { observable } from "mobx";
24 |
25 | const x = observable.box(0);
26 |
27 | export function Demo() {
28 | useSomeHeavyComputation(); // Render is expensive
29 |
30 | return (
31 |
40 |
47 |
48 | );
49 | }
50 | ```
51 |
52 | When observable `x` changes, `style.transform` will be re-evaluated and updated, but the component itself will not re-render.
53 |
54 | ## Example with spring animation
55 |
56 | In the example above, when `x` changes, the component will instantly move to the new position.
57 |
58 | We can easily use spring animation to make the movement smooth.
59 |
60 | The only change we need to make is to pass `x` value through `$spring` function inside the getter.
61 |
62 | Change this:
63 |
64 | ```tsx
65 | style={{
66 | get transform() {
67 | const transformX = x.get();
68 |
69 | return `translate(${transformX}px, 0)`;
70 | },
71 | }}
72 | ```
73 |
74 | To this:
75 |
76 | ```tsx
77 | style={{
78 | get transform() {
79 | const transformX = $spring(x.get());
80 |
81 | return `translate(${transformX}px, 0)`;
82 | },
83 | }}
84 | ```
85 |
86 | And that's it! Now when `x` changes, transform will be animated to the new value. Component will not re-render during the animation.
87 |
88 | The full example with spring animation:
89 |
90 | ```tsx
91 | import { mobxmotion, $spring } from "mobxmotion";
92 | import { observable } from "mobx";
93 |
94 | const x = observable.box(0);
95 |
96 | export function Demo() {
97 | return (
98 |
107 |
114 |
115 | );
116 | }
117 | ```
118 |
119 | > [!NOTE]
120 | >
121 | > `$spring` calls inside getters have to follow same rules as React hooks.
122 | >
123 | > They cannot be called conditionally, they have to be called on every getter call. You can use multiple `$spring` calls in one getter, but you have to call it the same number of times in every getter call.
124 |
125 | ---
126 |
127 | All the other props are exactly the same as in normal `div`.
128 |
129 | You can still use regular styles, without getters, as you would do with a normal `div`.
130 |
131 | Every existing `div` can be replaced with `mobxmotion.div` without any changes to the code.
132 |
133 | You can also use `mobxmotion.p`, `mobxmotion.span`, `mobxmotion.button`, etc. for every `HTML` and `SVG` element.
134 |
135 | ## License
136 |
137 | MIT License
138 |
139 | Copyright (c) 2024 Adam Pietrasiak
140 |
141 | Permission is hereby granted, free of charge, to any person obtaining a copy
142 | of this software and associated documentation files (the "Software"), to deal
143 | in the Software without restriction, including without limitation the rights
144 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
145 | copies of the Software, and to permit persons to whom the Software is
146 | furnished to do so, subject to the following conditions:
147 |
148 | The above copyright notice and this permission notice shall be included in all
149 | copies or substantial portions of the Software.
150 |
151 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
152 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
153 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
154 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
155 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
156 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
157 | SOFTWARE.
158 |
--------------------------------------------------------------------------------
/mobxmotion/demo/Demo.tsx:
--------------------------------------------------------------------------------
1 | import { $spring, mobxmotion } from "@/index";
2 | import React, { useEffect, useRef } from "react";
3 | import { configure, observable, runInAction } from "mobx";
4 |
5 | configure({
6 | enforceActions: "always",
7 | });
8 |
9 | const value = observable.box(0);
10 |
11 | function randomInt(min: number, max: number) {
12 | return Math.floor(Math.random() * (max - min + 1)) + min;
13 | }
14 |
15 | function useRendersCount() {
16 | const renders = useRef(1);
17 |
18 | useEffect(() => {
19 | renders.current++;
20 | }, []);
21 |
22 | return renders.current;
23 | }
24 |
25 | export function Demo() {
26 | const rendersCount = useRendersCount();
27 |
28 | return (
29 |
37 |
46 |
Renders: {rendersCount}
47 |
48 | );
49 | }
50 |
--------------------------------------------------------------------------------
/mobxmotion/demo/app.tsx:
--------------------------------------------------------------------------------
1 | import { Demo } from "./Demo";
2 | import React from "react";
3 | import { createRoot } from "react-dom/client";
4 |
5 | const rootElement = document.getElementById("root");
6 |
7 | if (rootElement) {
8 | const root = createRoot(rootElement);
9 | root.render();
10 | }
11 |
--------------------------------------------------------------------------------
/mobxmotion/demo/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Stylings Demo
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/mobxmotion/demo/index.tsx:
--------------------------------------------------------------------------------
1 | import "./app";
2 |
--------------------------------------------------------------------------------
/mobxmotion/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "mobxmotion",
3 | "homepage": "https://mobxmotion.com",
4 | "license": "MIT",
5 | "repository": {
6 | "type": "git",
7 | "url": "https://github.com/pie6k/mobxmotion.git"
8 | },
9 | "version": "1.0.4",
10 | "type": "module",
11 | "author": {
12 | "name": "Adam Pietrasiak"
13 | },
14 | "description": "Performant, zero-re-render animations for React using MobX.",
15 | "keywords": [
16 | "react",
17 | "mobx",
18 | "animation",
19 | "spring",
20 | "motion",
21 | "animations"
22 | ],
23 | "main": "./dist/index.cjs.js",
24 | "module": "./dist/index.es.js",
25 | "types": "./dist/index.d.ts",
26 | "exports": {
27 | ".": {
28 | "import": "./dist/index.es.js",
29 | "require": "./dist/index.cjs.js"
30 | }
31 | },
32 | "scripts": {
33 | "dev": "vite --config vite.config.demo.ts",
34 | "build": "vite --config vite.config.build.ts build",
35 | "preview": "vite preview",
36 | "prettier": "prettier --write \"src/**/*.{ts,tsx}\"",
37 | "test": "vitest"
38 | },
39 | "devDependencies": {
40 | "@types/node": "^22.13.11",
41 | "@types/react": "*",
42 | "@types/react-dom": "*",
43 | "mobx": "*",
44 | "prettier": "^3.0.3",
45 | "react": "*",
46 | "react-dom": "*",
47 | "typescript": "*",
48 | "vite": "^4.4.9",
49 | "vite-plugin-dts": "^4.5.3",
50 | "vitest": "^3.0.9"
51 | },
52 | "files": [
53 | "dist"
54 | ],
55 | "peerDependencies": {
56 | "mobx": "*",
57 | "react": "*"
58 | },
59 | "packageManager": "yarn@4.4.1"
60 | }
61 |
--------------------------------------------------------------------------------
/mobxmotion/src/AnimatedSpring.ts:
--------------------------------------------------------------------------------
1 | import { Spring } from "./Spring";
2 | import { SpringConfigInput } from "./springConfig";
3 | import { raf } from "./utils";
4 |
5 | export class AnimatedSpring extends Spring {
6 | constructor(
7 | initialValue: number,
8 | config?: SpringConfigInput,
9 | private targetWindow?: Window | null,
10 | ) {
11 | super(initialValue, config);
12 | }
13 |
14 | setTargetValue(value: number) {
15 | if (this.targetValue === value) return;
16 |
17 | const wasAtRest = this.isAtRest;
18 |
19 | super.setTargetValue(value);
20 |
21 | if (wasAtRest && !this.isAtRest && !this.isAnimating) {
22 | this.animateWhileNotAtRest();
23 | }
24 | }
25 |
26 | private isAnimating = false;
27 |
28 | private async animateWhileNotAtRest() {
29 | const targetWindow = this.targetWindow ?? window;
30 | if (this.isAnimating) {
31 | console.warn("Spring is already animating");
32 | return;
33 | }
34 |
35 | this.isAnimating = true;
36 |
37 | let lastFrameTime = await raf(targetWindow);
38 |
39 | while (!this.isAtRest) {
40 | if (!this.isAnimating) break;
41 |
42 | const time = await raf(targetWindow);
43 |
44 | if (!this.isAnimating) break;
45 |
46 | const deltaTime = time - lastFrameTime;
47 |
48 | lastFrameTime = time;
49 |
50 | super.advanceTimeBy(deltaTime);
51 | }
52 |
53 | this.isAnimating = false;
54 | }
55 |
56 | stop() {
57 | this.isAnimating = false;
58 | super.stop();
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/mobxmotion/src/MobxMotion.tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent, HTMLAttributes, RefObject, forwardRef, useEffect, useState } from "react";
2 | import { StyleWithTransforms, applyStyleAndVariables, computeStyleAndVariables, getChangedProperties } from "./style";
3 | import { reaction, untracked } from "mobx";
4 | import { useInnerForwardRef, useIsomorphicLayoutEffect } from "./hooks";
5 |
6 | import { SpringsManager } from "./springs";
7 | import { shallowEqual } from "./utils";
8 |
9 | type HTMLOrSVGElement = HTMLElement | SVGElement;
10 |
11 | export interface MobxMotionProps
12 | extends Omit, "style"> {
13 | ref?: RefObject;
14 | as?: keyof HTMLElementTagNameMap;
15 | style?: StyleWithTransforms;
16 | }
17 |
18 | export type MobxMotionComponent = FunctionComponent>;
19 |
20 | export function createMobxMotionComponent(
21 | componentName: keyof HTMLElementTagNameMap | keyof SVGElementTagNameMap,
22 | ): MobxMotionComponent {
23 | const MobxMotion = forwardRef>(function MobxMotion(
24 | { style, as = componentName, ...props },
25 | forwardedRef,
26 | ) {
27 | const ref = useInnerForwardRef(forwardedRef);
28 | const [springsManager] = useState(() => new SpringsManager(ref));
29 |
30 | useIsomorphicLayoutEffect(() => {
31 | if (!style) {
32 | springsManager.clear();
33 | return;
34 | }
35 |
36 | springsManager.clearUnused(Object.keys(style));
37 |
38 | const element = ref.current;
39 |
40 | if (!element) return;
41 |
42 | return reaction(
43 | () => {
44 | return computeStyleAndVariables(style, springsManager);
45 | },
46 | (stylesAndVariables, previousStylesAndVariables) => {
47 | const changedProperties = getChangedProperties(stylesAndVariables, previousStylesAndVariables);
48 |
49 | applyStyleAndVariables(element, changedProperties);
50 | },
51 | { equals: shallowEqual },
52 | );
53 | }, [style]);
54 |
55 | useEffect(() => {
56 | return () => {
57 | // not unmounting
58 | if (ref.current) return;
59 |
60 | springsManager.clear();
61 | };
62 | }, []);
63 |
64 | const Element = as as "div";
65 |
66 | return (
67 | }
69 | {...(props as MobxMotionProps)}
70 | style={untracked(() => {
71 | if (!style) return undefined;
72 |
73 | return computeStyleAndVariables(style, springsManager);
74 | })}
75 | />
76 | );
77 | });
78 |
79 | MobxMotion.displayName = `MobxMotion__${componentName}`;
80 |
81 | return MobxMotion;
82 | }
83 |
--------------------------------------------------------------------------------
/mobxmotion/src/Spring.ts:
--------------------------------------------------------------------------------
1 | import { SpringConfig, SpringConfigInput, resolveSpringConfigInput, validateSpringConfig } from "./springConfig";
2 |
3 | import { createAtom } from "mobx";
4 | import { stepSpring } from "./springStep";
5 |
6 | function getIsValidNumber(input: number) {
7 | if (typeof input !== "number") return false;
8 |
9 | if (!isFinite(input)) return false;
10 |
11 | if (isNaN(input)) return false;
12 |
13 | return true;
14 | }
15 |
16 | export function calculatePrecission(from: number, to: number, precisionBase: number) {
17 | if (from === to) {
18 | return 1;
19 | }
20 |
21 | const diff = Math.max(1, Math.abs(from - to));
22 |
23 | const precisionDividre = 1 / precisionBase;
24 |
25 | return Math.max(diff, 1) / precisionDividre;
26 | }
27 |
28 | /**
29 | * Physics based spring, that is not based on browser timers, but instead requires manually advancing time.
30 | *
31 | * This allows generating animation in-advance, while being able to modify target values at any point, which is not possible with one-shoot "generateSpringFrames" libs
32 | *
33 | * Note: this will be called extremely often and needs to be well optimized
34 | */
35 | export class Spring {
36 | public config: SpringConfig;
37 |
38 | public time: number = 0; // Current time along the spring curve in ms (zero-based)
39 |
40 | private valueAtom = createAtom("NumberSpring");
41 |
42 | private _value: number; // the current value of the spring
43 |
44 | get value() {
45 | this.valueAtom.reportObserved();
46 | return this._value;
47 | }
48 |
49 | set value(value: number) {
50 | if (this._value === value) return;
51 |
52 | this._value = value;
53 | this.valueAtom.reportChanged();
54 | }
55 |
56 | public currentVelocity: number = 0; // the current velocity of the spring
57 |
58 | public targetValue: number;
59 |
60 | /**
61 | * Precission is automatically set basing on offset between from<>to
62 | */
63 | private precission: number = 1;
64 |
65 | constructor(initialValue: number, input: SpringConfigInput = {}) {
66 | this._value = initialValue;
67 | this.targetValue = initialValue;
68 | this.config = resolveSpringConfigInput(input);
69 |
70 | validateSpringConfig(this.config);
71 | }
72 |
73 | /**
74 | * If the spring has reached its `toValue`, or if its velocity is below the
75 | * `restVelocityThreshold`, it is considered at rest. If `stop()` is called
76 | * during a simulation, both `isAnimating` and `isAtRest` will be false.
77 | */
78 | get isAtRest(): boolean {
79 | return this.targetValue === this.value && this.currentVelocity === 0;
80 | }
81 |
82 | snapToTarget(target = this.targetValue) {
83 | this.value = target;
84 | this.targetValue = target;
85 | this.currentVelocity = 0;
86 | }
87 |
88 | setTargetValue(targetValue: number) {
89 | if (this.targetValue === targetValue) return;
90 |
91 | if (!getIsValidNumber(targetValue)) {
92 | throw new Error("Invalid target value");
93 | }
94 |
95 | this.precission = calculatePrecission(this.value, targetValue, this.config.precision);
96 |
97 | this.targetValue = targetValue;
98 | }
99 |
100 | private lastConfigUpdate?: SpringConfigInput;
101 |
102 | /**
103 | * Updates the spring config with the given values. Values not explicitly
104 | * supplied will be reused from the existing config.
105 | */
106 | updateConfig(updatedConfig: Partial) {
107 | if (this.lastConfigUpdate === updatedConfig) return;
108 |
109 | const newConfig: SpringConfig = {
110 | ...this.config,
111 | ...updatedConfig,
112 | };
113 |
114 | validateSpringConfig(newConfig);
115 |
116 | this.config = newConfig;
117 | this.lastConfigUpdate = updatedConfig;
118 | }
119 |
120 | advanceTimeTo(time: number) {
121 | const dt = time - this.time;
122 |
123 | this.advanceTimeBy(dt);
124 | }
125 |
126 | advanceTimeBy(dt: number) {
127 | if (dt === 0) return;
128 | if (dt < 0) throw new Error("Can't go back in time");
129 |
130 | this.time += dt;
131 |
132 | if (this.isAtRest) {
133 | return;
134 | }
135 |
136 | // Hello, Einstein!
137 | if (this.config.mass === 0) {
138 | this.snapToTarget();
139 | return;
140 | }
141 |
142 | [this.value, this.currentVelocity] = stepSpring(
143 | dt,
144 | this._value,
145 | this.currentVelocity,
146 | this.targetValue,
147 | this.config.stiffness,
148 | this.config.damping,
149 | this.config.mass,
150 | this.config.clamp,
151 | this.precission,
152 | );
153 | }
154 |
155 | stop() {
156 | this.setTargetValue(this._value);
157 | this.snapToTarget();
158 | }
159 | }
160 |
--------------------------------------------------------------------------------
/mobxmotion/src/components.tsx:
--------------------------------------------------------------------------------
1 | import { createMobxMotionComponent } from "./MobxMotion";
2 |
3 | export const mobxmotion = {
4 | a: createMobxMotionComponent("a"),
5 | abbr: createMobxMotionComponent("abbr"),
6 | address: createMobxMotionComponent("address"),
7 | area: createMobxMotionComponent("area"),
8 | article: createMobxMotionComponent("article"),
9 | aside: createMobxMotionComponent("aside"),
10 | audio: createMobxMotionComponent("audio"),
11 | b: createMobxMotionComponent("b"),
12 | base: createMobxMotionComponent("base"),
13 | bdi: createMobxMotionComponent("bdi"),
14 | bdo: createMobxMotionComponent("bdo"),
15 | blockquote: createMobxMotionComponent("blockquote"),
16 | body: createMobxMotionComponent("body"),
17 | br: createMobxMotionComponent("br"),
18 | button: createMobxMotionComponent("button"),
19 | canvas: createMobxMotionComponent("canvas"),
20 | caption: createMobxMotionComponent("caption"),
21 | cite: createMobxMotionComponent("cite"),
22 | code: createMobxMotionComponent("code"),
23 | col: createMobxMotionComponent("col"),
24 | colgroup: createMobxMotionComponent("colgroup"),
25 | data: createMobxMotionComponent("data"),
26 | datalist: createMobxMotionComponent("datalist"),
27 | dd: createMobxMotionComponent("dd"),
28 | del: createMobxMotionComponent("del"),
29 | details: createMobxMotionComponent("details"),
30 | dfn: createMobxMotionComponent("dfn"),
31 | dialog: createMobxMotionComponent("dialog"),
32 | div: createMobxMotionComponent("div"),
33 | dl: createMobxMotionComponent("dl"),
34 | dt: createMobxMotionComponent("dt"),
35 | em: createMobxMotionComponent("em"),
36 | embed: createMobxMotionComponent("embed"),
37 | fieldset: createMobxMotionComponent("fieldset"),
38 | figcaption: createMobxMotionComponent("figcaption"),
39 | figure: createMobxMotionComponent("figure"),
40 | footer: createMobxMotionComponent("footer"),
41 | form: createMobxMotionComponent("form"),
42 | h1: createMobxMotionComponent("h1"),
43 | h2: createMobxMotionComponent("h2"),
44 | h3: createMobxMotionComponent("h3"),
45 | h4: createMobxMotionComponent("h4"),
46 | h5: createMobxMotionComponent("h5"),
47 | h6: createMobxMotionComponent("h6"),
48 | head: createMobxMotionComponent("head"),
49 | header: createMobxMotionComponent("header"),
50 | hr: createMobxMotionComponent("hr"),
51 | html: createMobxMotionComponent("html"),
52 | i: createMobxMotionComponent("i"),
53 | iframe: createMobxMotionComponent("iframe"),
54 | img: createMobxMotionComponent("img"),
55 | input: createMobxMotionComponent("input"),
56 | ins: createMobxMotionComponent("ins"),
57 | kbd: createMobxMotionComponent("kbd"),
58 | label: createMobxMotionComponent("label"),
59 | legend: createMobxMotionComponent("legend"),
60 | li: createMobxMotionComponent("li"),
61 | link: createMobxMotionComponent("link"),
62 | main: createMobxMotionComponent("main"),
63 | map: createMobxMotionComponent("map"),
64 | mark: createMobxMotionComponent("mark"),
65 | menu: createMobxMotionComponent("menu"),
66 | meta: createMobxMotionComponent("meta"),
67 | meter: createMobxMotionComponent("meter"),
68 | nav: createMobxMotionComponent("nav"),
69 | noscript: createMobxMotionComponent("noscript"),
70 | object: createMobxMotionComponent("object"),
71 | ol: createMobxMotionComponent("ol"),
72 | optgroup: createMobxMotionComponent("optgroup"),
73 | option: createMobxMotionComponent("option"),
74 | output: createMobxMotionComponent("output"),
75 | p: createMobxMotionComponent("p"),
76 | picture: createMobxMotionComponent("picture"),
77 | pre: createMobxMotionComponent("pre"),
78 | progress: createMobxMotionComponent("progress"),
79 | q: createMobxMotionComponent("q"),
80 | rp: createMobxMotionComponent("rp"),
81 | rt: createMobxMotionComponent("rt"),
82 | ruby: createMobxMotionComponent("ruby"),
83 | s: createMobxMotionComponent("s"),
84 | samp: createMobxMotionComponent("samp"),
85 | script: createMobxMotionComponent("script"),
86 | section: createMobxMotionComponent("section"),
87 | select: createMobxMotionComponent("select"),
88 | small: createMobxMotionComponent("small"),
89 | source: createMobxMotionComponent("source"),
90 | span: createMobxMotionComponent("span"),
91 | strong: createMobxMotionComponent("strong"),
92 | style: createMobxMotionComponent("style"),
93 | sub: createMobxMotionComponent("sub"),
94 | summary: createMobxMotionComponent("summary"),
95 | sup: createMobxMotionComponent("sup"),
96 | table: createMobxMotionComponent("table"),
97 | tbody: createMobxMotionComponent("tbody"),
98 | td: createMobxMotionComponent("td"),
99 | template: createMobxMotionComponent("template"),
100 | textarea: createMobxMotionComponent("textarea"),
101 | tfoot: createMobxMotionComponent("tfoot"),
102 | th: createMobxMotionComponent("th"),
103 | thead: createMobxMotionComponent("thead"),
104 | time: createMobxMotionComponent("time"),
105 | title: createMobxMotionComponent("title"),
106 | tr: createMobxMotionComponent("tr"),
107 | track: createMobxMotionComponent("track"),
108 | u: createMobxMotionComponent("u"),
109 | ul: createMobxMotionComponent("ul"),
110 | var: createMobxMotionComponent("var"),
111 | video: createMobxMotionComponent("video"),
112 | wbr: createMobxMotionComponent("wbr"),
113 | // SVG elements
114 | circle: createMobxMotionComponent("circle"),
115 | clipPath: createMobxMotionComponent("clipPath"),
116 | defs: createMobxMotionComponent("defs"),
117 | desc: createMobxMotionComponent("desc"),
118 | ellipse: createMobxMotionComponent("ellipse"),
119 | feBlend: createMobxMotionComponent("feBlend"),
120 | feColorMatrix: createMobxMotionComponent("feColorMatrix"),
121 | feComponentTransfer: createMobxMotionComponent("feComponentTransfer"),
122 | feComposite: createMobxMotionComponent("feComposite"),
123 | feConvolveMatrix: createMobxMotionComponent("feConvolveMatrix"),
124 | feDiffuseLighting: createMobxMotionComponent("feDiffuseLighting"),
125 | feDisplacementMap: createMobxMotionComponent("feDisplacementMap"),
126 | feDistantLight: createMobxMotionComponent("feDistantLight"),
127 | feDropShadow: createMobxMotionComponent("feDropShadow"),
128 | feFlood: createMobxMotionComponent("feFlood"),
129 | feFuncA: createMobxMotionComponent("feFuncA"),
130 | feFuncB: createMobxMotionComponent("feFuncB"),
131 | feFuncG: createMobxMotionComponent("feFuncG"),
132 | feFuncR: createMobxMotionComponent("feFuncR"),
133 | feGaussianBlur: createMobxMotionComponent("feGaussianBlur"),
134 | feImage: createMobxMotionComponent("feImage"),
135 | feMerge: createMobxMotionComponent("feMerge"),
136 | feMergeNode: createMobxMotionComponent("feMergeNode"),
137 | feMorphology: createMobxMotionComponent("feMorphology"),
138 | feOffset: createMobxMotionComponent("feOffset"),
139 | fePointLight: createMobxMotionComponent("fePointLight"),
140 | feSpecularLighting: createMobxMotionComponent("feSpecularLighting"),
141 | feSpotLight: createMobxMotionComponent("feSpotLight"),
142 | feTile: createMobxMotionComponent("feTile"),
143 | feTurbulence: createMobxMotionComponent("feTurbulence"),
144 | filter: createMobxMotionComponent("filter"),
145 | foreignObject: createMobxMotionComponent("foreignObject"),
146 | g: createMobxMotionComponent("g"),
147 | image: createMobxMotionComponent("image"),
148 | line: createMobxMotionComponent("line"),
149 | linearGradient: createMobxMotionComponent("linearGradient"),
150 | marker: createMobxMotionComponent("marker"),
151 | mask: createMobxMotionComponent("mask"),
152 | metadata: createMobxMotionComponent("metadata"),
153 | mpath: createMobxMotionComponent("mpath"),
154 | path: createMobxMotionComponent("path"),
155 | pattern: createMobxMotionComponent("pattern"),
156 | polygon: createMobxMotionComponent("polygon"),
157 | polyline: createMobxMotionComponent("polyline"),
158 | radialGradient: createMobxMotionComponent("radialGradient"),
159 | rect: createMobxMotionComponent("rect"),
160 | set: createMobxMotionComponent("set"),
161 | stop: createMobxMotionComponent("stop"),
162 | switch: createMobxMotionComponent("switch"),
163 | symbol: createMobxMotionComponent("symbol"),
164 | text: createMobxMotionComponent("text"),
165 | textPath: createMobxMotionComponent("textPath"),
166 | tspan: createMobxMotionComponent("tspan"),
167 | use: createMobxMotionComponent("use"),
168 | view: createMobxMotionComponent("view"),
169 | animate: createMobxMotionComponent("animate"),
170 | animateMotion: createMobxMotionComponent("animateMotion"),
171 | animateTransform: createMobxMotionComponent("animateTransform"),
172 | // big: createMobxMotionComponent("big"),
173 | // center: createMobxMotionComponent("center"),
174 | hgroup: createMobxMotionComponent("hgroup"),
175 | // keygen: createMobxMotionComponent("keygen"),
176 | // menuitem: createMobxMotionComponent("menuitem"),
177 | // noindex: createMobxMotionComponent("noindex"),
178 | // param: createMobxMotionComponent("param"),
179 | search: createMobxMotionComponent("svg"),
180 | slot: createMobxMotionComponent("slot"),
181 | svg: createMobxMotionComponent("svg"),
182 | // webview: createMobxMotionComponent("webview"),
183 | };
184 |
185 | export type MobxMotionComponents = typeof mobxmotion;
186 |
--------------------------------------------------------------------------------
/mobxmotion/src/hooks.ts:
--------------------------------------------------------------------------------
1 | import { DependencyList, useLayoutEffect } from "react";
2 | import { ForwardedRef, RefObject, useMemo } from "react";
3 |
4 | export function applyValueToForwardedRef(forwardedRef: ForwardedRef, value: T) {
5 | if (typeof forwardedRef === "function") {
6 | forwardedRef(value);
7 | } else if (forwardedRef != null) {
8 | forwardedRef.current = value;
9 | }
10 | }
11 |
12 | export function useInnerForwardRef(forwardedRef: ForwardedRef) {
13 | const innerRefObject = useMemo>(() => {
14 | let currentValue: T | null = null;
15 |
16 | return {
17 | get current() {
18 | return currentValue;
19 | },
20 | set current(value) {
21 | currentValue = value;
22 | applyValueToForwardedRef(forwardedRef, value);
23 | },
24 | };
25 | }, [forwardedRef]);
26 |
27 | return innerRefObject;
28 | }
29 |
30 | const IS_SERVER = typeof window === "undefined";
31 |
32 | export function useIsomorphicLayoutEffect(effect: () => void, deps: DependencyList) {
33 | if (IS_SERVER) return;
34 |
35 | useLayoutEffect(() => {
36 | effect();
37 | }, deps);
38 | }
39 |
--------------------------------------------------------------------------------
/mobxmotion/src/index.ts:
--------------------------------------------------------------------------------
1 | export { mobxmotion } from "./components";
2 | export { type MobxMotionComponent, type MobxMotionProps } from "./MobxMotion";
3 | export { type SpringConfigInput } from "./springConfig";
4 | export { $spring } from "./springs";
5 |
--------------------------------------------------------------------------------
/mobxmotion/src/springConfig.ts:
--------------------------------------------------------------------------------
1 | export interface SpringConfig {
2 | /**
3 | * It tells how much "target value" wants to bring current value to itself.
4 | *
5 | * It means the further away the current value is from the target value, the bigger force will be applied if stiffness is big.
6 | *
7 | * F = DISTANCE TO TARGET * STIFFNESS
8 | */
9 | stiffness: number;
10 | /**
11 | * "Stopping force" that works against current movement (velocity). It's like air-resistance.
12 | * The faster the object moves, the more resistance it will have. The slower it moves, the less resistance it will have.
13 | * F = CURRENT VELOCITY * DAMPING
14 | *
15 | * It can be thought of as "stopping easing" - if it's bigger, fast moving objects will stop faster.
16 | * It will barely affect "getting up to speed" as slowly moving object will not have much 'resistance' - you need mass for that.
17 | */
18 | damping: number;
19 | /**
20 | * The bigger the mass, the more object wants to keep its "current velocity".
21 | *
22 | * It means it will "get up to speed" more slowly, but will also "slow down" more slowly.
23 | *
24 | * It can be thought of as "starting easing"
25 | */
26 | mass: number;
27 |
28 | /**
29 | * If true, value will never overshoot the target value. It will stop at the target value
30 | */
31 | clamp: boolean;
32 |
33 | /**
34 | * If set, value will be rounded to this precision
35 | */
36 | precision: number;
37 | }
38 |
39 | export type SpringConfigInput = Partial;
40 |
41 | export const DEFAULT_SPRING_CONFIG: SpringConfig = {
42 | stiffness: 300,
43 | damping: 30,
44 | mass: 1,
45 | clamp: false,
46 | precision: 0.002,
47 | };
48 |
49 | export function resolveSpringConfigInput(input?: SpringConfigInput): SpringConfig {
50 | return {
51 | ...DEFAULT_SPRING_CONFIG,
52 | ...input,
53 | };
54 | }
55 |
56 | export function validateSpringConfig(input: SpringConfig) {
57 | if (input.mass! < 0) {
58 | throw new Error("Mass value must be greater or equal 0");
59 | }
60 |
61 | if (input.stiffness! <= 0) {
62 | throw new Error("Stiffness value must be greater than 0");
63 | }
64 |
65 | if (input.precision! <= 0) {
66 | throw new Error("Precision must be greater than 0");
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/mobxmotion/src/springStep.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Actual physics emulation happens here. This is heavily optimized for performance.
3 | *
4 | * Note: for better precision we sample steps in 1ms intervals, but we only return the last step, so if we calculate 1 frame in 60fps (16ms) - we'll actually perform 16 steps.
5 | * This is because it is possible that spring will move over 'central' point in one 16fps step, so force should change direction in the meanwhile, otherwise spring would keep accelerating on the other side.
6 | * It would be not a 'critical' bug as spring would still settle, but it would ignore subtle 'wiggle' at the end of the animation.
7 | */
8 |
9 | /**
10 | * Each step returns new x and new velocitiy. As those are called thousands of times per second, we reuse the same array to avoid GC and constant array creation.
11 | * Important: as a result of this, result of this function should instantly be destructured and never mutated, eg const [newX, newV] = stepSpringOne(...)
12 | */
13 | let reusedResult: [number, number] = [0, 0];
14 |
15 | function returnReused(x: number, v: number) {
16 | reusedResult[0] = x;
17 | reusedResult[1] = v;
18 | return reusedResult;
19 | }
20 |
21 | const USE_PRECISE_EMULATION = true;
22 |
23 | function stepSpringBy(
24 | deltaMS: number,
25 | currentX: number,
26 | currentVelocity: number,
27 | targetX: number,
28 | stiffness: number,
29 | damping: number,
30 | mass: number,
31 | clamp: boolean,
32 | precision: number,
33 | ): [newX: number, newV: number] {
34 | const deltaS = deltaMS / 1000;
35 |
36 | /**
37 | * The further we are from target, the more force spring tension will apply
38 | */
39 | const springTensionForce = -(currentX - targetX) * stiffness;
40 | /**
41 | * The faster we are moving, the more force friction force will be applied
42 | */
43 | const frictionForce = -currentVelocity * damping;
44 |
45 | // the bigger the mass, the less 'raw' force will actually affect the movement
46 | const finalForce = (springTensionForce + frictionForce) / mass;
47 |
48 | const newVelocity = currentVelocity + finalForce * deltaS;
49 | const newX = currentX + newVelocity * deltaS;
50 |
51 | if (clamp) {
52 | if (currentX < targetX && newX > targetX) {
53 | return returnReused(targetX, 0);
54 | }
55 |
56 | if (currentX > targetX && newX < targetX) {
57 | return returnReused(targetX, 0);
58 | }
59 | }
60 |
61 | const newDistanceToTarget = Math.abs(newX - targetX);
62 |
63 | // When both velocity and distance to target are under the precision, we 'snap' to the target and stop the spring
64 | // Otherwise - spring would keep moving, slower and slower, forever as it's energy would never fall to 0
65 | if (Math.abs(newVelocity) < precision && newDistanceToTarget < precision) {
66 | return returnReused(targetX, 0);
67 | }
68 |
69 | return returnReused(newX, newVelocity);
70 | }
71 |
72 | export function stepSpring(
73 | deltaMS: number,
74 | currentX: number,
75 | currentV: number,
76 | targetX: number,
77 | stiffness: number,
78 | damping: number,
79 | mass: number,
80 | clamp: boolean,
81 | precision: number,
82 | ): [newX: number, newV: number] {
83 | if (!USE_PRECISE_EMULATION) {
84 | return stepSpringBy(deltaMS, currentX, currentV, targetX, stiffness, damping, mass, clamp, precision);
85 | }
86 |
87 | const upperDeltaMS = Math.ceil(deltaMS);
88 |
89 | if (upperDeltaMS > 10_000) {
90 | throw new Error("Spring emulation is too long, finishing simulation");
91 | }
92 |
93 | for (let i = 1; i <= upperDeltaMS; i++) {
94 | // Last, sub-1ms step - do precise emulation
95 | if (i > deltaMS) {
96 | [currentX, currentV] = stepSpringBy(
97 | i - deltaMS,
98 | currentX,
99 | currentV,
100 | targetX,
101 | stiffness,
102 | damping,
103 | mass,
104 | clamp,
105 | precision,
106 | );
107 | } else {
108 | // Emulate in 1ms steps
109 | [currentX, currentV] = stepSpringBy(1, currentX, currentV, targetX, stiffness, damping, mass, clamp, precision);
110 | }
111 | }
112 |
113 | return returnReused(currentX, currentV);
114 | }
115 |
--------------------------------------------------------------------------------
/mobxmotion/src/springs.ts:
--------------------------------------------------------------------------------
1 | import { AnimatedSpring } from "./AnimatedSpring";
2 | import { RefObject } from "react";
3 | import { SpringConfigInput } from "./springConfig";
4 | import { getElementOwnerWindow } from "./utils";
5 |
6 | interface CurrentlyComputingStyleProperty {
7 | springsManager: SpringsManager | null;
8 | property: string;
9 | springIndex: number;
10 | }
11 |
12 | let currentlyComputingStyleProperty: CurrentlyComputingStyleProperty = {
13 | springsManager: null,
14 | property: "",
15 | springIndex: 0,
16 | };
17 |
18 | type ElementRef = RefObject;
19 |
20 | export function setCurrentlyComputingStyleProperty(springsManager: SpringsManager, property: string) {
21 | currentlyComputingStyleProperty.springsManager = springsManager;
22 | currentlyComputingStyleProperty.property = property;
23 | currentlyComputingStyleProperty.springIndex = 0;
24 | }
25 |
26 | export function clearCurrentlyComputingStyleProperty() {
27 | currentlyComputingStyleProperty.springsManager = null;
28 | }
29 |
30 | export function $spring(value: number, options?: SpringConfigInput): number {
31 | if (!currentlyComputingStyleProperty.springsManager) {
32 | throw new Error("$spring can only be used inside style getters of MobxMotion components");
33 | }
34 |
35 | return currentlyComputingStyleProperty.springsManager.getSpringValue(
36 | currentlyComputingStyleProperty.property,
37 | currentlyComputingStyleProperty.springIndex++,
38 | value,
39 | options,
40 | );
41 | }
42 |
43 | const EMPTY_SPRING_CONFIG: SpringConfigInput = {};
44 |
45 | export class SpringsManager {
46 | private springs: Map = new Map();
47 |
48 | constructor(private ref: ElementRef) {}
49 |
50 | get element() {
51 | return this.ref.current;
52 | }
53 |
54 | getSpringValue(property: string, springIndex: number, value: number, options?: SpringConfigInput): number {
55 | const element = this.element;
56 |
57 | // It is likely the first render, don't initialize spring yet. Simply return input value.
58 | if (!element) {
59 | return value;
60 | }
61 |
62 | let springs = this.springs.get(property);
63 |
64 | if (!springs) {
65 | springs = [];
66 | this.springs.set(property, springs);
67 | }
68 |
69 | let spring = springs[springIndex];
70 |
71 | if (!spring) {
72 | spring = new AnimatedSpring(value, options, getElementOwnerWindow(element));
73 | springs[springIndex] = spring;
74 | } else {
75 | spring.setTargetValue(value);
76 | spring.updateConfig(options ?? EMPTY_SPRING_CONFIG);
77 | }
78 |
79 | return spring.value;
80 | }
81 |
82 | clearUnused(usedProperties: string[]) {
83 | for (let [property, springs] of this.springs.entries()) {
84 | if (usedProperties.includes(property)) continue;
85 |
86 | springs = [...springs];
87 |
88 | this.springs.delete(property);
89 |
90 | for (const spring of springs) {
91 | spring.stop();
92 | }
93 | }
94 | }
95 |
96 | clear() {
97 | const springs = [...this.springs.values()].flat();
98 |
99 | this.springs.clear();
100 |
101 | for (const spring of springs) {
102 | spring.stop();
103 | }
104 | }
105 | }
106 |
--------------------------------------------------------------------------------
/mobxmotion/src/style.ts:
--------------------------------------------------------------------------------
1 | import { SpringsManager, clearCurrentlyComputingStyleProperty, setCurrentlyComputingStyleProperty } from "./springs";
2 |
3 | import { CSSProperties } from "react";
4 | import { typedEntries } from "./utils";
5 |
6 | type HTMLOrSVGElement = HTMLElement | SVGElement;
7 |
8 | type AnyVariableStyle = {
9 | [key in `--${string}`]: string | number;
10 | };
11 |
12 | type TransformProperties = {
13 | x?: number;
14 | y?: number;
15 | rotateZ?: number;
16 | scale?: number;
17 | };
18 |
19 | export type StyleWithTransforms = CSSProperties & AnyVariableStyle & TransformProperties;
20 | export type StyleAndVariables = CSSProperties & AnyVariableStyle;
21 |
22 | function resolveStyleInput(style: StyleWithTransforms): StyleAndVariables {
23 | const { x, y, rotateZ, scale, ...cssProperties } = style;
24 |
25 | let finalTransforms: string[] = cssProperties.transform ? [cssProperties.transform] : [];
26 |
27 | if (x !== undefined) {
28 | finalTransforms.push(`translateX(${x}px)`);
29 | }
30 |
31 | if (y !== undefined) {
32 | finalTransforms.push(`translateY(${y}px)`);
33 | }
34 |
35 | if (rotateZ !== undefined) {
36 | finalTransforms.push(`rotateZ(${rotateZ}deg)`);
37 | }
38 |
39 | if (scale !== undefined) {
40 | finalTransforms.push(`scale(${scale})`);
41 | }
42 |
43 | const finalTransform = finalTransforms.length > 0 ? finalTransforms.join(" ") : "";
44 |
45 | if (finalTransform) {
46 | cssProperties.transform = finalTransform;
47 | }
48 |
49 | return cssProperties;
50 | }
51 |
52 | export function computeStyleAndVariables(
53 | style: StyleWithTransforms,
54 | springsManager: SpringsManager,
55 | ): StyleAndVariables {
56 | const computedStyles: StyleWithTransforms = {};
57 |
58 | for (const key in style) {
59 | try {
60 | setCurrentlyComputingStyleProperty(springsManager, key);
61 | /**
62 | * style[key] can be a getter
63 | */
64 | // @ts-ignore
65 | computedStyles[key] = style[key];
66 | clearCurrentlyComputingStyleProperty();
67 | } catch (error) {
68 | clearCurrentlyComputingStyleProperty();
69 | throw error;
70 | }
71 | }
72 |
73 | return resolveStyleInput(computedStyles);
74 | }
75 |
76 | function getIsCSSVariableString(key: string): key is `--${string}` {
77 | return key.startsWith("--");
78 | }
79 |
80 | export function getChangedProperties(
81 | styleAndVariables: StyleAndVariables,
82 | previousStylesAndVariables: StyleAndVariables,
83 | ): StyleAndVariables {
84 | const changedProperties: StyleAndVariables = {};
85 |
86 | for (const [key, value] of typedEntries(styleAndVariables)) {
87 | if (previousStylesAndVariables[key] !== value) {
88 | // @ts-ignore
89 | changedProperties[key] = value;
90 | }
91 | }
92 |
93 | return changedProperties;
94 | }
95 |
96 | export function applyStyleAndVariables(element: HTMLOrSVGElement, styleAndVariables: StyleAndVariables) {
97 | const style = element.style;
98 |
99 | for (const [key, value] of typedEntries(styleAndVariables)) {
100 | if (getIsCSSVariableString(key)) {
101 | style.setProperty(key, `${value}`);
102 | } else {
103 | // @ts-ignore
104 | style[key] = value;
105 | }
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/mobxmotion/src/utils.ts:
--------------------------------------------------------------------------------
1 | import { action } from "mobx";
2 |
3 | export function getElementOwnerWindow(element: Element) {
4 | return element.ownerDocument.defaultView;
5 | }
6 |
7 | export type AnyObject = Record;
8 |
9 | export function typedKeys(input: O): Array {
10 | return Object.keys(input as any) as Array;
11 | }
12 |
13 | export function typedEntries(input: T): Array<[keyof T, T[keyof T]]> {
14 | return Object.entries(input) as Array<[keyof T, T[keyof T]]>;
15 | }
16 |
17 | export function shallowEqual(a: unknown, b: unknown) {
18 | if (a === b) return true;
19 |
20 | if (typeof a !== "object" || a === null || typeof b !== "object" || b === null) return false;
21 |
22 | const keysA = Object.keys(a);
23 | const keysB = Object.keys(b);
24 |
25 | if (keysA.length !== keysB.length) return false;
26 |
27 | for (const key of keysA) {
28 | // @ts-ignore
29 | if (a[key] !== b[key]) return false;
30 | }
31 |
32 | return true;
33 | }
34 |
35 | const currentRafPromise = new WeakMap>();
36 |
37 | export function raf(targetWindow: Window = window) {
38 | let promise = currentRafPromise.get(targetWindow);
39 |
40 | if (promise) return promise;
41 |
42 | promise = new Promise((resolve) => {
43 | targetWindow.requestAnimationFrame(action(resolve));
44 | }).finally(() => {
45 | currentRafPromise.delete(targetWindow);
46 | });
47 |
48 | currentRafPromise.set(targetWindow, promise);
49 |
50 | return promise;
51 | }
52 |
--------------------------------------------------------------------------------
/mobxmotion/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "./dist",
5 | "rootDir": "./src"
6 | },
7 | "exclude": ["demo"]
8 | }
9 |
--------------------------------------------------------------------------------
/mobxmotion/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "module": "ESNext",
5 | "useDefineForClassFields": true,
6 | "lib": ["ESNext", "DOM"],
7 | "allowJs": true,
8 | "jsx": "react",
9 | "moduleResolution": "Node",
10 | "strict": true,
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "esModuleInterop": true,
14 | "noEmit": true,
15 | "noUnusedLocals": true,
16 | "noUnusedParameters": true,
17 | "noImplicitReturns": true,
18 | "skipLibCheck": true,
19 | "baseUrl": ".",
20 | "paths": {
21 | "@/*": ["src/*"],
22 | "@": ["src"]
23 | }
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/mobxmotion/vite.config.build.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import dts from "vite-plugin-dts";
3 | import fs from "fs";
4 | import { resolve } from "path";
5 |
6 | function collectExternalDependencies() {
7 | const packageJson = fs.readFileSync(resolve(__dirname, "package.json"), "utf8");
8 | const dependencies = JSON.parse(packageJson).dependencies ?? {};
9 | const peerDependencies = JSON.parse(packageJson).peerDependencies ?? {};
10 | return Object.keys(dependencies).concat(Object.keys(peerDependencies));
11 | }
12 |
13 | export default defineConfig({
14 | build: {
15 | lib: {
16 | formats: ["es", "cjs"],
17 | entry: resolve(__dirname, "src", "index.ts"),
18 | fileName: (format) => `index.${format}.js`,
19 | },
20 | outDir: resolve(__dirname, "dist"),
21 | rollupOptions: {
22 | external: collectExternalDependencies(),
23 | },
24 | minify: false,
25 | emptyOutDir: true,
26 | sourcemap: false,
27 | },
28 | plugins: [dts({ tsconfigPath: resolve(__dirname, "tsconfig.build.json"), rollupTypes: true })],
29 | });
30 |
--------------------------------------------------------------------------------
/mobxmotion/vite.config.demo.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import { resolve } from "path";
3 |
4 | export default defineConfig({
5 | root: resolve(__dirname, "demo"),
6 | resolve: {
7 | alias: {
8 | "@": resolve(__dirname, "src"),
9 | },
10 | },
11 | });
12 |
--------------------------------------------------------------------------------
/mobxmotion/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vitest/config";
2 | import { resolve } from "path";
3 |
4 | export default defineConfig({
5 | resolve: {
6 | alias: {
7 | "@": resolve(__dirname, "src"),
8 | },
9 | },
10 | test: {
11 | // ...
12 | },
13 | });
14 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "mobxmotion-root",
3 | "version": "1.0.0",
4 | "main": "index.js",
5 | "private": true,
6 | "workspaces": {
7 | "packages": [
8 | "mobxmotion",
9 | "docs"
10 | ]
11 | },
12 | "scripts": {
13 | "mobxmotion": "yarn workspace mobxmotion",
14 | "docs": "yarn workspace mobxmotion-docs"
15 | },
16 | "packageManager": "yarn@4.4.1"
17 | }
18 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://openapi.vercel.sh/vercel.json",
3 | "version": 2,
4 | "buildCommand": "yarn build",
5 | "outputDirectory": ".next",
6 | "installCommand": "yarn install",
7 | "framework": "nextjs"
8 | }
9 |
--------------------------------------------------------------------------------