├── .eslintrc.yaml ├── .gitignore ├── .prettierrc.yaml ├── README-v3.md ├── README.md ├── assets ├── autoshuffle.gif ├── inout.gif ├── inoutpic2.gif ├── logo.gif ├── nav-spring.gif ├── nav.gif ├── sharedlayout.gif └── simpleshuffle.gif ├── jest.config.js ├── package.json ├── src ├── AnimateInOut.tsx ├── FlipProvider.tsx ├── const.ts ├── createKeyframes.ts ├── easings.ts ├── helpers.ts ├── index.ts ├── keyframes.ts ├── syncLayout.ts ├── useFlip.ts └── useLayoutEffect.ts ├── tsconfig.json └── yarn.lock /.eslintrc.yaml: -------------------------------------------------------------------------------- 1 | parserOptions: 2 | ecmaVersion: 2018 3 | sourceType: module 4 | env: 5 | es6: true 6 | browser: true 7 | node: true 8 | plugins: 9 | - prettier 10 | - react 11 | - react-hooks 12 | extends: 13 | - plugin:prettier/recommended 14 | - eslint:recommended 15 | - plugin:react/recommended 16 | - react-app 17 | rules: 18 | no-console: warn 19 | no-eval: error 20 | prettier/prettier: error 21 | react-hooks/rules-of-hooks: error 22 | react-hooks/exhaustive-deps: warn 23 | react/prop-types: [2, { ignore: ['children'] }] 24 | react/react-in-jsx-scope: off 25 | settings: 26 | react: 27 | version: detect 28 | -------------------------------------------------------------------------------- /.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 | # VSCode settings and caches 9 | .* 10 | 11 | # testing 12 | /coverage 13 | 14 | # production 15 | /build 16 | 17 | # package 18 | /lib 19 | 20 | # misc 21 | .DS_Store 22 | .env.local 23 | .env.development.local 24 | .env.test.local 25 | .env.production.local 26 | 27 | npm-debug.log* 28 | yarn-debug.log* 29 | yarn-error.log* 30 | 31 | .npmignore 32 | 33 | public/ 34 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | trailingComma: none 2 | tabWidth: 2 3 | semi: false 4 | singleQuote: true 5 | arrowParens: always 6 | -------------------------------------------------------------------------------- /README-v3.md: -------------------------------------------------------------------------------- 1 | # React Easy Flip 2 | 3 | ⚛ A lightweight React library for smooth FLIP animations 4 | 5 | ## Demo 6 | 7 | https://demo.jlkiri.now.sh/ 8 | 9 | ## Install 10 | 11 | `npm install react-easy-flip` 12 | 13 | ## Get started 14 | 15 | The library consists of two independent hooks: `useSimpleFlip` and `useFlipGroup`. Use `useFlipGroup` if you need to animate position or size of an indefinite number of children (see examples below). Use `useSimpleFlip` for everything else. 16 | 17 | 1. Import `useSimpleFlip` hook: 18 | 19 | ```javascript 20 | import { useSimpleFlip } from 'react-easy-flip' 21 | ``` 22 | 23 | 2. Pick a unique `data-flip-id` and assign it to the element you want to animate 24 | 3. Use the hook by passing it the id and dependencies that you would normally pass to `useEffect` (e.g. an array that is used to render children): 25 | 26 | ```javascript 27 | useSimpleFlip({ flipId: myId, flag: myDeps }) 28 | ``` 29 | 30 | ## Usage 31 | 32 | ### useSimpleFlip 33 | 34 | `useSimpleAnimation` requires one argument, which is minimally an object with the `id` of your animated element and `boolean` hook [dependencies](https://reactjs.org/docs/hooks-reference.html#conditionally-firing-an-effect). You may optionally provide: 35 | 36 | a) a callback to be executed after an animation is done: `onTransitionEnd` 37 | b) CSS transition options 38 | c) a special flag that tells the hook whether the transition is "shared" 39 | 40 | | Field | Required | Type | Details | 41 | | :---------------: | :------: | :--------: | :-------------------------------------------------------------------------------: | 42 | | `flipId` | `true` | `string` | A React reference to a parent element which contains children you want to animate | 43 | | `flag` | `true` | `boolean` | Hook dependencies | 44 | | `opts` | `false` | `object` | Animation options object | 45 | | `onTransitionEnd` | `false` | `function` | A callback to be executed after an animation is done | 46 | | `isShared` | `false` | `boolean` | A special flag that tells the hook whether the transition is "shared" | 47 | 48 | ### useFlipGroup 49 | 50 | `useFlipGroup` requires one argument, which is minimally an object with the `id` of your animated element and hook [dependencies](https://reactjs.org/docs/hooks-reference.html#conditionally-firing-an-effect). You _must_ also attach a unique `data-id` to every child that you want to animate (see examples below). The `data-id` can be the same as a `key` prop. 51 | 52 | Just like in `useSimpleFlip` you may optionally provide: 53 | 54 | a) a callback to be executed after an animation is done: `onTransitionEnd` 55 | b) CSS transition options 56 | 57 | | Field | Required | Type | Details | 58 | | :---------------: | :------: | :--------: | :-------------------------------------------------------------------------------: | 59 | | `flipId` | `true` | `string` | A React reference to a parent element which contains children you want to animate | 60 | | `deps` | `true` | `any` | Hook dependencies | 61 | | `opts` | `false` | `object` | Animation options object | 62 | | `onTransitionEnd` | `false` | `function` | A callback to be executed after an animation is done | 63 | 64 | ### Options in detail 65 | 66 | You may add an `opts` options object to the argument of `useSimpleFlip` or `useFlipGroup`. It allows you to specify CSS transition duration, easing function and animation delay: 67 | 68 | | Field | Default | Type | Details | 69 | | :----------: | :------: | :------: | :---------------------------------------------: | 70 | | `transition` | `700` | `number` | Transition duration (in milliseconds) | 71 | | `easing` | `"ease"` | `string` | Animation easing function (any valid CSS value) | 72 | | `delay` | `0` | `number` | Animation delay (in milliseconds) | 73 | 74 | Usage example: 75 | 76 | ```javascript 77 | const opts = { 78 | transition: 200, 79 | easing: 'cubic-bezier(0.39, 0.575, 0.565, 1)', 80 | delay: 300 81 | } 82 | 83 | useSimpleFlip({ flipId: 'uniqueId', opts, flag: isClicked }) 84 | ``` 85 | 86 | ## Comparison with other libraries 87 | 88 | Among similar libraries such as [`react-overdrive`](https://github.com/berzniz/react-overdrive), [`react-flip-move`](https://github.com/joshwcomeau/react-flip-move) or [`react-flip-toolkit`](https://github.com/aholachek/react-flip-toolkit) that are based on a [FLIP technique](https://aerotwist.com/blog/flip-your-animations/), this library's capabilities match those of `react-flip-toolkit`. 89 | 90 | `react-easy-flip` can animate both position and scale, as well as prevent distortion of children of an animated element when its scale is changed. It also allows you to easily do so-called ["shared element transitions"](https://guides.codepath.com/android/shared-element-activity-transition) (e.g. smoothly translate an element from one page to another). The examples are given below. 91 | 92 | Additionally, `react-easy-flip` is the **only** FLIP library for React that provides animation via a hook. `react-easy-flip` has the **smallest bundle size**. It does not use React class components and lifecycle methods that are considered unsafe in latest releases of React. 93 | 94 | ## Examples 95 | 96 | The code for the demo above can be found in this repository [here](https://github.com/jlkiri/react-easy-flip/tree/master/demo). 97 | Below are some simple Codesandbox examples: 98 | 99 | ### Translate and scale (with child scale adjustment) 100 | 101 | This examples shows the difference between the same transition done in CSS and with `react-easy-flip`: 102 | 103 | https://codesandbox.io/s/css-vs-js-child-warping-t7f95 104 | 105 | ![Simple](./assets/simple.gif) 106 | 107 | ### Shuffle children 108 | 109 | This is a good usecase for `useFlipGroup`: 110 | 111 | https://codesandbox.io/s/list-shuffling-flip-hlguz 112 | 113 | ![Simple](./assets/shuffle.gif) 114 | 115 | ### Shared element transition 116 | 117 | This is an example of so-called shared element transition. Click on any square with the Moon in it. Note that the black background and the Moon are different elements (with different parents) after the click, yet the animation remains smooth despite the DOM unmount. This technique is done fairly easiy with `useSimpleFlip` and can even be used to animate elements across pages in SPA. 118 | 119 | https://codesandbox.io/s/shared-element-transition-flip-9orsy 120 | 121 | ![Shared](./assets/sharedtransition.gif) 122 | 123 | ## Tips 124 | 125 | - You may need to think about how you structure your HTML before you try to "customize" animations with your own CSS (it will probably not work) 126 | - `useFlipGroup` is best used with a `transitionend` callback: disable animation-triggering actions until animation is finished to prevent possible layout problems 127 | - It is possible to use more than one instance of each hooks within one React component but make sure to: 128 | a) Keep `id`s unique 129 | b) Avoid making one `id` a target for more than one animation 130 | 131 | ## Requirements 132 | 133 | This library requires React version 16.8.0 or higher (the one with Hooks). 134 | 135 | ## Done in 3.0 136 | 137 | - [x] Full Typescript support 138 | - [x] Add support for animating scale and shared element transitions 139 | - [x] Add comprehensive examples 140 | - [x] Add tests 141 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![npm bundle size](https://img.shields.io/bundlephobia/minzip/react-easy-flip) 2 | ![npm (tag)](https://img.shields.io/npm/v/react-easy-flip) 3 | 4 |

5 | react-easy-flip animation logo 6 |

7 | 8 | # React Easy Flip 9 | 10 | ⚛ A lightweight React library for smooth FLIP animations 11 | 12 | ## Features 13 | 14 | - Animates the unanimatable (DOM positions, mounts/unmounts) 15 | 16 | - One hook for many usecases 17 | 18 | - Uses the [Web Animations API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API) (WAAPI) 19 | 20 | * Stable and smooth 60fps animations 21 | 22 | - SSR-friendly 23 | 24 | * Built-in easing functions 25 | 26 | - Lightweight 27 | 28 | ## Previous README versions 29 | 30 | This is a README for v4. The v3 README can be found [here](./README-v3.md). 31 | 32 | ## Demo 33 | 34 | https://react-easy-flip-demo.now.sh/ 35 | 36 | Repository: [react-easy-flip-demo](https://github.com/jlkiri/react-easy-flip-demo) 37 | 38 | You can also [read about how it works in detail here](https://css-tricks.com/everything-you-need-to-know-about-flip-animations-in-react/). 39 | 40 | ## Install 41 | 42 | `npm install react-easy-flip@beta` 43 | 44 | ## Get started 45 | 46 | 1. Import `useFlip` hook and `FlipProvider`: 47 | 48 | ```javascript 49 | import { useFlip, FlipProvider } from 'react-easy-flip' 50 | ``` 51 | 52 | 2. Wrap your app (or at least a component that contains animated children) with a `FlipProvider` 53 | 54 | ```jsx 55 | 56 | 57 | 58 | ``` 59 | 60 | 3. Assign a `data-flip-root-id` to any parent of the element(s) you want to animate 61 | 62 | ```jsx 63 |
64 | 65 |
66 | ``` 67 | 68 | 4. Pick a unique `data-flip-id` and assign it to the element(s) you want to animate. It can be the same as a `key` prop 69 | 70 | ```jsx 71 | 72 | ``` 73 | 74 | 5. Use the hook by passing it the root ID you picked in (3) 75 | 76 | ```javascript 77 | useFlip(rootId) 78 | ``` 79 | 80 | And that's it! 81 | 82 | ## Usage details 83 | 84 | ### useFlip 85 | 86 | `useFlip` requires one argument, which is an ID of the root, i.e. any parent whose children you want to animate. You can optionally pass an options object with animation options (see details below) as a second argument. Third argument is the optional dependencies which you would normally pass to a `useEffect` hook: use it if you need to explicitly tell `react-easy-flip` that items you want to animate changed. 87 | 88 | ``` 89 | useFlip(rootId, animationOptions, deps) 90 | ``` 91 | 92 | #### Animation optons 93 | 94 | Animation options is an object. 95 | 96 | | Property | Default | Required | Type | Details | 97 | | :------------: | :------------: | :------: | :--------: | :-----------------------------------------------------------: | 98 | | `duration` | 400 | `false` | `number` | Animation duration (ms) | 99 | | `easing` | `easeOutCubic` | `false` | `function` | Easing function (that can be imported from `react-easy-flip`) | 100 | | `delay` | 0 | `false` | `number` | Animation delay | 101 | | `animateColor` | false | `false` | `boolean` | Animate background color of the animated element | 102 | 103 | Example: 104 | 105 | ```javascript 106 | import { easeInOutQuint } from 'react-easy-flip` 107 | 108 | const SomeReactComponent = () => { 109 | const animationOptions = { 110 | duration: 2000, 111 | easing: easeInOutQuint, 112 | } 113 | 114 | useFlip(rootId, animationOptions) 115 | 116 | return ( 117 |
118 |
119 |
120 | ) 121 | } 122 | ``` 123 | 124 | ### Exported easings 125 | 126 | `react-easy-flip` exports ready-to-use easing functions. You can [see the examples here](https://easings.net/). 127 | 128 | - linear 129 | - easeInSine 130 | - easeOutSine 131 | - easeInOutSine 132 | - easeInCubic 133 | - easeOutCubic 134 | - easeInOutCubic 135 | - easeInQuint 136 | - easeOutQuint 137 | - easeInOutQuint 138 | - easeInBack 139 | - easeOutBack 140 | - easeInOutBack 141 | 142 | ### AnimateInOut (experimental) 143 | 144 | While `useFlip` can animate all kinds of position changes, it does not animate mount/unmount animations (e.g. fade in/out). For this purpose the `` component is also exported. To use it, simple wrap with it the components/elements which you want to be animated. By default the initial render is not animated, but this can be changed with a prop. 145 | 146 | Every element wrapped with a `` **must** have a unique key prop. 147 | 148 | Example: 149 | 150 | ```javascript 151 | import { AnimateInOut } from 'react-easy-flip` 152 | 153 | const SomeReactComponent = () => { 154 | return ( 155 | 156 |
157 |
158 |
159 | 160 | ) 161 | } 162 | ``` 163 | 164 | Here are all props that you can pass to ``: 165 | 166 | | Property | Default | Required | Type | Details | 167 | | :-----------------: | :---------: | :------: | :------------------: | :------------------------------------------------------------: | 168 | | `in` | `fadeIn` | `false` | `AnimationKeyframes` | Mount animation options | 169 | | `out` | `fadeOut` | `false` | `AnimationKeyframes` | Unmount animation options | 170 | | `playOnFirstRender` | `false` | `false` | `boolean` | Animate on first render | 171 | | `itemAmount` | `undefined` | `false` | `number` | An explicit amount of current children (see explanation below) | 172 | 173 | What is `itemAmount` for? In most cases this is not needed. But if your element is animated with a shared layout transition (such as moving from one list to another), this means that it doesn't need an unmount animation. In order to avoid two animations being applied to one element, provide the amount. For example, if this is a todo-app-like application, keep the number of both todo and done items. Moving from todo to done doesn't change the total amount of items, but `` does not know that until you tell it. See the recipes below. 174 | 175 | ## Comparison with other libraries 176 | 177 | - `react-easy-flip` uses Web Animations API (WAAPI) for animations. No other library based on a [FLIP technique](https://aerotwist.com/blog/flip-your-animations/) currently does that. 178 | 179 | - Similar to existing libraries such as [`react-overdrive`](https://github.com/berzniz/react-overdrive), [`react-flip-move`](https://github.com/joshwcomeau/react-flip-move) or [`react-flip-toolkit`](https://github.com/aholachek/react-flip-toolkit) (although only the latter seems to be maintained). 180 | 181 | - Allows you to easily do so-called [shared layout animations](https://guides.codepath.com/android/shared-element-activity-transition) (e.g. smoothly move an element from one page/parent to another). Some examples are given below. This is what heavier libraries like [`framer-motion`](https://github.com/framer/motion) call Magic Motion. 182 | 183 | - Additionally, `react-easy-flip` is the **only** lightweight FLIP library for React that provides animation via a hook. Currently `react-easy-flip` has the **smallest bundle size**. It also does not use React class components and lifecycle methods that are considered unsafe in latest releases of React. 184 | 185 | ## Recipes 186 | 187 | ### List sort/shuffle animation 188 | 189 | [Go to code](https://github.com/jlkiri/react-easy-flip-demo/blob/master/pages/Shuffle.tsx) 190 | 191 |

192 | simple shuffle animation 193 |

194 | 195 | ### Both x and y coordinate shuffle 196 | 197 | [Go to code](https://github.com/jlkiri/react-easy-flip-demo/blob/master/pages/auto-shuffle.tsx) 198 | 199 |

200 | auto shuffle animation 201 |

202 | 203 | ### Shared layout animation 204 | 205 | This is an todo-app-like example of shared layout animations. Click on any rectangle to move it to another parent. Note that on every click an item is actually unmounted from DOM and re-mounted in the other position, but having the same `data-flip-id` allows to be smoothly animated from one to another position. 206 | 207 | [Go to code](https://github.com/jlkiri/react-easy-flip-demo/blob/master/pages/shared-layout.tsx) 208 | 209 |

210 | shared layout  animation 211 |

212 | 213 | ### Shared layout animation (navigation) 214 | 215 | One nice usecase for shared layout animation is navigation bars where we want to move the highlighting indicator smoothly between tabs. 216 | 217 | [Go to code](https://github.com/jlkiri/react-easy-flip-demo/blob/master/pages/magic-nav.tsx) 218 | 219 |

220 | shared layout  animation 221 |

222 | 223 | ### In/out (mount/unmount) animation (opacity) 224 | 225 | The fade in and out keyframes are default and work out of box (= you do not need to explicitly pass them). 226 | 227 | [Go to code](https://github.com/jlkiri/react-easy-flip-demo/blob/master/pages/in-out.tsx) 228 | 229 |

230 | shared layout  animation 231 |

232 | 233 | ### In/out (mount/unmount) animation (translation) 234 | 235 | An example of passing custom animation options to ``. Here the images are moved in and out instead of simply fading in and out. 236 | 237 | [Go to code](https://github.com/jlkiri/react-easy-flip-demo/blob/master/pages/in-out-pic.tsx) 238 | 239 |

240 | shared layout  animation 241 |

242 | 243 | ## Requirements 244 | 245 | This library requires React version 16.8.0 or higher (the one with Hooks). 246 | 247 | ## Contribution 248 | 249 | Any kind of contribution is welcome! 250 | 251 | 1. Open an issue or pick an existing one that you want to work on 252 | 2. Fork this repository 253 | 3. Clone your fork to work on it locally 254 | 4. Make changes 255 | 5. Run `yarn build:dev` and make sure that it builds without crash 256 | -------------------------------------------------------------------------------- /assets/autoshuffle.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlkiri/react-easy-flip/10f1f0976e2fe9031718a53029e7fbd26b623b9c/assets/autoshuffle.gif -------------------------------------------------------------------------------- /assets/inout.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlkiri/react-easy-flip/10f1f0976e2fe9031718a53029e7fbd26b623b9c/assets/inout.gif -------------------------------------------------------------------------------- /assets/inoutpic2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlkiri/react-easy-flip/10f1f0976e2fe9031718a53029e7fbd26b623b9c/assets/inoutpic2.gif -------------------------------------------------------------------------------- /assets/logo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlkiri/react-easy-flip/10f1f0976e2fe9031718a53029e7fbd26b623b9c/assets/logo.gif -------------------------------------------------------------------------------- /assets/nav-spring.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlkiri/react-easy-flip/10f1f0976e2fe9031718a53029e7fbd26b623b9c/assets/nav-spring.gif -------------------------------------------------------------------------------- /assets/nav.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlkiri/react-easy-flip/10f1f0976e2fe9031718a53029e7fbd26b623b9c/assets/nav.gif -------------------------------------------------------------------------------- /assets/sharedlayout.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlkiri/react-easy-flip/10f1f0976e2fe9031718a53029e7fbd26b623b9c/assets/sharedlayout.gif -------------------------------------------------------------------------------- /assets/simpleshuffle.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlkiri/react-easy-flip/10f1f0976e2fe9031718a53029e7fbd26b623b9c/assets/simpleshuffle.gif -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'jsdom', 4 | globals: { 5 | 'ts-jest': { 6 | tsConfig: '/tsconfig.test.json' 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-easy-flip", 3 | "version": "4.0.3", 4 | "description": "A lightweight React library for smooth FLIP animations", 5 | "source": "src/index.ts", 6 | "types": "lib/index.d.ts", 7 | "main": "lib/index.js", 8 | "module": "lib/index.es.js", 9 | "scripts": { 10 | "build": "microbundle -f es,cjs --jsx React.createElement --no-sourcemap", 11 | "build:dev": "microbundle -f es,cjs --jsx React.createElement", 12 | "prepare": "yarn build", 13 | "format": "prettier --write src/**/*.{ts,tsx,js,jsx}", 14 | "lint": "eslint src/ --ext .js,.ts,.tsx,.jsx", 15 | "lint:fix": "eslint --fix src/ --ext .js,.ts,.tsx,.jsx", 16 | "develop": "yarn start" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+ssh://git@github.com/jlkiri/react-easy-flip.git" 21 | }, 22 | "keywords": [ 23 | "react", 24 | "FLIP", 25 | "animation", 26 | "transition" 27 | ], 28 | "author": "Kirill Vasiltsov", 29 | "license": "MIT", 30 | "bugs": { 31 | "url": "https://github.com/jlkiri/react-easy-flip/issues" 32 | }, 33 | "homepage": "https://github.com/jlkiri/react-easy-flip#readme", 34 | "peerDependencies": { 35 | "react": ">= 16.8.0", 36 | "react-dom": ">= 16.8.0" 37 | }, 38 | "devDependencies": { 39 | "@testing-library/react-hooks": "^3.1.1", 40 | "@types/node": "^12.12.5", 41 | "@types/react": "^16.9.11", 42 | "@types/react-dom": "^16.9.3", 43 | "eslint": "^6.6.0", 44 | "eslint-config-prettier": "^6.4.0", 45 | "eslint-plugin-prettier": "^3.1.1", 46 | "eslint-plugin-react": "^7.16.0", 47 | "eslint-plugin-react-hooks": "^2.2.0", 48 | "microbundle": "^0.12.0", 49 | "prettier": "^1.19.1", 50 | "react": "^16.13.1", 51 | "react-dom": "^16.13.1", 52 | "react-scripts": "3.2.0", 53 | "typescript": "^3.9.7" 54 | }, 55 | "files": [ 56 | "lib/**/*" 57 | ], 58 | "browserslist": { 59 | "production": [ 60 | "> 1%", 61 | "last 2 versions", 62 | "not dead" 63 | ], 64 | "development": [ 65 | "last 1 chrome version", 66 | "last 1 firefox version", 67 | "last 1 safari version" 68 | ] 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/AnimateInOut.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { FlipContext } from './FlipProvider' 3 | import { isRunning, getComputedBgColor, getRect } from './helpers' 4 | import { fadeIn, fadeOut } from './keyframes' 5 | 6 | export { fadeIn, fadeOut } 7 | export { AnimateInOut } 8 | 9 | interface CustomKeyframe { 10 | [property: string]: string | number 11 | } 12 | 13 | interface AnimationKeyframes { 14 | from: CustomKeyframe 15 | to: CustomKeyframe 16 | duration: number 17 | easing?: string 18 | } 19 | 20 | interface AnimateInOutProps { 21 | children: React.ReactNode 22 | in?: AnimationKeyframes 23 | out?: AnimationKeyframes 24 | itemAmount?: number 25 | playOnFirstRender?: boolean 26 | } 27 | 28 | interface InOutChildProps { 29 | children: React.ReactElement 30 | childProps: React.ReactElement['props'] 31 | callback?: () => void 32 | keyframes: { in: AnimationKeyframes; out: AnimationKeyframes } 33 | preventAnimation?: boolean 34 | isCached?: boolean 35 | isExiting: boolean 36 | } 37 | 38 | const getChildKey = (child: React.ReactElement) => { 39 | return `${child.key}` || '' 40 | } 41 | 42 | const onlyValidElements = (children: React.ReactNode) => { 43 | const filtered: React.ReactElement[] = [] 44 | 45 | React.Children.forEach(children, (child) => { 46 | if (React.isValidElement(child)) { 47 | filtered.push(child) 48 | } 49 | }) 50 | 51 | return filtered 52 | } 53 | 54 | const InOutChild = (props: InOutChildProps) => { 55 | const ref = React.useRef(null) 56 | const hasRendered = React.useRef(false) 57 | const localCachedAnimation = React.useRef(null) 58 | const { cachedStyles } = React.useContext(FlipContext) 59 | 60 | React.useLayoutEffect(() => { 61 | // Skip animations on non-relevant renders (neither exiting nor appearing) 62 | if (props.preventAnimation) { 63 | hasRendered.current = true 64 | return 65 | } 66 | 67 | if (hasRendered.current && !props.isExiting) return 68 | 69 | if (!ref.current) return 70 | 71 | if (!props.isExiting && props.isCached) return // TODO: is this condition needed? 72 | 73 | const cachedAnimation = localCachedAnimation.current 74 | 75 | // If currently playing exiting animation keep playing 76 | if (cachedAnimation && isRunning(cachedAnimation)) return 77 | 78 | const { in: inKfs, out } = props.keyframes 79 | const keyframes = props.isExiting ? out : inKfs 80 | 81 | const kfe = new KeyframeEffect( 82 | ref.current, 83 | [keyframes.from, keyframes.to], 84 | { 85 | duration: keyframes.duration, 86 | easing: keyframes.easing || 'ease', 87 | fill: 'both' 88 | } 89 | ) 90 | 91 | const animation = new Animation(kfe, document.timeline) 92 | 93 | const flipId = props.children.props['data-flip-id'] 94 | 95 | // Delete from common cache on exit 96 | if (props.isExiting) { 97 | animation.onfinish = () => { 98 | cachedStyles.delete(flipId) 99 | props.callback && props.callback() 100 | } 101 | } 102 | 103 | // Set position only after entering animation is finished. If not 104 | // it may have 0 width and height and cause scale problems 105 | if (!props.isExiting) { 106 | animation.onfinish = () => { 107 | cachedStyles.set(flipId, { 108 | styles: { 109 | bgColor: getComputedBgColor(ref.current!) 110 | }, 111 | rect: getRect(ref.current!) 112 | }) 113 | } 114 | } 115 | 116 | animation.play() 117 | 118 | localCachedAnimation.current = animation 119 | 120 | hasRendered.current = true 121 | }, [props, cachedStyles]) 122 | 123 | // Prevent interactions with exiting elements (treat as non-existent) 124 | const style = { ...(props.children.props.style || {}), pointerEvents: 'none' } 125 | 126 | return props.isExiting 127 | ? React.cloneElement(props.children, { 128 | ...props.children.props, 129 | style, 130 | 'data-flip-id': undefined, // Prevent trigger of shared layout animations on exiting elements 131 | ref 132 | }) 133 | : React.cloneElement(props.children, { ...props.children.props, ref }) 134 | } 135 | 136 | const AnimateInOut = ({ 137 | children, 138 | in: inKeyframes = fadeIn, 139 | out: outKeyframes = fadeOut, 140 | playOnFirstRender = false, 141 | itemAmount = undefined 142 | }: AnimateInOutProps): any => { 143 | const { forceRender, childKeyCache } = React.useContext(FlipContext) 144 | const exiting = React.useRef(new Set()).current 145 | const previousAmount = React.useRef(itemAmount) 146 | const initialRender = React.useRef(true) 147 | 148 | // TODO: If there is a playing flip animation during an entering animation 149 | // the behaviour of the flipped element is weird. 150 | 151 | const kfs = { in: inKeyframes, out: outKeyframes } 152 | 153 | // Use an optional explicit hint to know when an element truly is removed 154 | // and not moved to other position in DOM (shared layout transition) 155 | const amountChanged = 156 | itemAmount === undefined || itemAmount !== previousAmount.current 157 | 158 | previousAmount.current = itemAmount 159 | 160 | const filteredChildren = onlyValidElements(children) 161 | 162 | const presentChildren = React.useRef(filteredChildren) 163 | 164 | React.useEffect(() => { 165 | React.Children.forEach(filteredChildren, (child) => { 166 | childKeyCache.set(getChildKey(child), child) 167 | }) 168 | }) 169 | 170 | // On initial render just wrap everything with InOutChild 171 | if (initialRender.current) { 172 | initialRender.current = false 173 | return filteredChildren.map((child) => ( 174 | 182 | {child} 183 | 184 | )) 185 | } 186 | 187 | // If render is caused by shared layout animation do not play exit animations 188 | // but keep those already playing 189 | if (!amountChanged) { 190 | if (exiting.size !== 0) { 191 | return presentChildren.current 192 | } 193 | 194 | let renderedChildren = filteredChildren.map((child) => { 195 | if (exiting.has(getChildKey(child))) { 196 | return child 197 | } 198 | 199 | return ( 200 | 207 | {child} 208 | 209 | ) 210 | }) 211 | 212 | presentChildren.current = renderedChildren 213 | 214 | return renderedChildren 215 | } 216 | 217 | const presentKeys = presentChildren.current.map(getChildKey) 218 | const targetKeys = filteredChildren.map(getChildKey) 219 | 220 | const removeFromDOM = (key: string) => { 221 | // Avoid bugs when callback is called twice (i.e. not exiting anymore) 222 | if (!exiting.has(key)) return 223 | 224 | childKeyCache.delete(key) 225 | exiting.delete(key) 226 | 227 | // Do not force render if multiple exit animations are playing 228 | const removeIndex = presentChildren.current.findIndex( 229 | (child) => child.key === key 230 | ) 231 | 232 | presentChildren.current.splice(removeIndex, 1) 233 | 234 | // Only force render when the last exit animation is finished 235 | if (exiting.size === 0) { 236 | presentChildren.current = filteredChildren 237 | forceRender() 238 | } 239 | } 240 | 241 | for (const key of presentKeys) { 242 | if (!targetKeys.includes(key)) { 243 | exiting.add(key) 244 | } else { 245 | // In case this key has re-entered, remove from the exiting list 246 | exiting.delete(key) 247 | } 248 | } 249 | 250 | let renderedChildren = [...filteredChildren] 251 | 252 | exiting.forEach((key) => { 253 | // If this component is actually entering again, early return. 254 | // Copied from framer-motion. Not sure what the usecase is but just in case. 255 | if (targetKeys.indexOf(key) !== -1) return 256 | 257 | const child = childKeyCache.get(key) 258 | 259 | if (!child) return 260 | 261 | // This is an animation onfinish callback 262 | const removeFromCache = () => removeFromDOM(key) 263 | 264 | const index = presentKeys.indexOf(key) 265 | 266 | renderedChildren.splice( 267 | index, 268 | 0, 269 | 277 | {child} 278 | 279 | ) 280 | }) 281 | 282 | // Wrap children in InOutChild except those that are exiting 283 | renderedChildren = renderedChildren.map((child) => { 284 | const childKey = getChildKey(child) 285 | if (exiting.has(childKey)) { 286 | return child 287 | } 288 | 289 | return ( 290 | 297 | {child} 298 | 299 | ) 300 | }) 301 | 302 | presentChildren.current = renderedChildren 303 | 304 | return renderedChildren 305 | } 306 | -------------------------------------------------------------------------------- /src/FlipProvider.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { isPaused, isRunning } from './helpers' 3 | 4 | export type Rect = DOMRect | ClientRect 5 | 6 | export type CachedStyles = Map< 7 | string, 8 | { styles: { bgColor: string }; rect: Rect } 9 | > 10 | export type Animations = Map 11 | export type ChildKeyCache = Map 12 | 13 | interface FlipContext { 14 | forceRender: () => void 15 | pauseAll: () => void 16 | resumeAll: () => void 17 | cachedAnimations: Animations 18 | cachedStyles: CachedStyles 19 | childKeyCache: ChildKeyCache 20 | } 21 | 22 | export const FlipContext = React.createContext({ 23 | forceRender: () => {}, 24 | pauseAll: () => {}, 25 | resumeAll: () => {}, 26 | cachedAnimations: new Map(), 27 | cachedStyles: new Map(), 28 | childKeyCache: new Map() 29 | }) 30 | 31 | export const FlipProvider = ({ children }: { children: React.ReactNode }) => { 32 | const [forcedRenders, setForcedRenders] = React.useState(0) 33 | const cachedAnimations = React.useRef(new Map()).current 34 | const cachedStyles = React.useRef(new Map()).current 35 | const childKeyCache = React.useRef(new Map()).current 36 | 37 | const ctx = React.useMemo(() => { 38 | return { 39 | forceRender: () => { 40 | setForcedRenders(forcedRenders + 1) 41 | }, 42 | pauseAll: () => { 43 | for (const animation of cachedAnimations.values()) { 44 | if (isRunning(animation)) { 45 | animation.pause() 46 | } 47 | } 48 | }, 49 | resumeAll: () => { 50 | for (const animation of cachedAnimations.values()) { 51 | if (isPaused(animation)) { 52 | animation.play() 53 | } 54 | } 55 | }, 56 | cachedAnimations, 57 | cachedStyles, 58 | childKeyCache 59 | } 60 | }, [forcedRenders, childKeyCache, cachedStyles, cachedAnimations]) 61 | 62 | return {children} 63 | } 64 | -------------------------------------------------------------------------------- /src/const.ts: -------------------------------------------------------------------------------- 1 | import { easeOutCubic } from './easings' 2 | 3 | export const DEFAULT_DURATION = 400 4 | export const DEFAULT_DELAY = 0 5 | export const DEFAULT_EASING = easeOutCubic 6 | -------------------------------------------------------------------------------- /src/createKeyframes.ts: -------------------------------------------------------------------------------- 1 | // See https://developers.google.com/web/updates/2017/03/performant-expand-and-collapse 2 | 3 | type CreateKeyframes = { 4 | sx: number 5 | sy: number 6 | dx?: number 7 | dy?: number 8 | easeFn: (x: number) => number 9 | calculateInverse?: boolean 10 | } 11 | 12 | let cachedEasings = new Map() 13 | let cachedLoops = new Map() 14 | 15 | export const createKeyframes = ({ 16 | sx = 1, 17 | sy = 1, 18 | dx = 0, 19 | dy = 0, 20 | easeFn, 21 | calculateInverse = false 22 | }: CreateKeyframes) => { 23 | const cacheKey = `${Math.round(sx)}${Math.round(sy)}${Math.round( 24 | dx 25 | )}${Math.round(dy)}` 26 | const cachedLoop = cachedLoops.get(cacheKey) 27 | 28 | if (cachedLoop) return cachedLoop 29 | 30 | // Figure out the size of the element when collapsed. 31 | let animations = [] 32 | let inverseAnimations = [] 33 | 34 | // Increase by 5, since by 1 works very poor in Firefox (but not in Chromium) 35 | for (let step = 0; step <= 100; step = step + 5) { 36 | // Remap the step value to an eased one. 37 | const nStep = step / 100 38 | const cachedV = cachedEasings.get(nStep) 39 | const easedStep = cachedV ? cachedV : easeFn(nStep) 40 | 41 | !cachedV && cachedEasings.set(nStep, easedStep) 42 | 43 | // Calculate the scale of the element. 44 | // easedStep grows from 0 to 1 according to the easing function. 45 | // To prevent changes when the scale value is 1 we substract it from 1 so that the multiplier is always 0 46 | const scaleX = sx + (1 - sx) * easedStep 47 | const scaleY = sy + (1 - sy) * easedStep 48 | const translateX = dx - dx * easedStep 49 | const translateY = dy - dy * easedStep 50 | 51 | animations.push({ 52 | transform: `scale(${scaleX}, ${scaleY}) translate(${translateX}px, ${translateY}px)` 53 | }) 54 | 55 | if (calculateInverse) { 56 | // And now the inverse for the contents. 57 | const invXScale = 1 / scaleX 58 | const invYScale = 1 / scaleY 59 | 60 | // TODO: Counter-translations? 61 | inverseAnimations.push({ 62 | transform: `scale(${invXScale}, ${invYScale}) translate(0px, 0px)` 63 | }) 64 | } 65 | } 66 | 67 | cachedLoops.set(cacheKey, { animations, inverseAnimations }) 68 | 69 | return { 70 | animations, 71 | inverseAnimations 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/easings.ts: -------------------------------------------------------------------------------- 1 | // https://easings.net/ 2 | 3 | export function linear(x: number) { 4 | return x 5 | } 6 | 7 | export function easeInSine(x: number): number { 8 | return 1 - Math.cos((x * Math.PI) / 2) 9 | } 10 | 11 | export function easeOutSine(x: number): number { 12 | return Math.sin((x * Math.PI) / 2) 13 | } 14 | 15 | export function easeInOutSine(x: number): number { 16 | return -(Math.cos(Math.PI * x) - 1) / 2 17 | } 18 | 19 | export function easeInCubic(x: number): number { 20 | return x * x * x 21 | } 22 | 23 | export function easeOutCubic(x: number): number { 24 | return 1 - Math.pow(1 - x, 3) 25 | } 26 | 27 | export function easeInOutCubic(x: number): number { 28 | return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2 29 | } 30 | 31 | export function easeInQuint(x: number): number { 32 | return x * x * x * x * x 33 | } 34 | 35 | export function easeOutQuint(x: number): number { 36 | return 1 - Math.pow(1 - x, 5) 37 | } 38 | 39 | export function easeInOutQuint(x: number): number { 40 | return x < 0.5 ? 16 * x * x * x * x * x : 1 - Math.pow(-2 * x + 2, 5) / 2 41 | } 42 | 43 | export function easeInBack(x: number): number { 44 | const c1 = 1.70158 45 | const c3 = c1 + 1 46 | return c3 * x * x * x - c1 * x * x 47 | } 48 | 49 | export function easeOutBack(x: number): number { 50 | const c1 = 1.70158 51 | const c3 = c1 + 1 52 | return 1 + c3 * Math.pow(x - 1, 3) + c1 * Math.pow(x - 1, 2) 53 | } 54 | 55 | export function easeInOutBack(x: number): number { 56 | const c1 = 1.70158 57 | const c2 = c1 * 1.525 58 | 59 | return x < 0.5 60 | ? (Math.pow(2 * x, 2) * ((c2 + 1) * 2 * x - c2)) / 2 61 | : (Math.pow(2 * x - 2, 2) * ((c2 + 1) * (x * 2 - 2) + c2) + 2) / 2 62 | } 63 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | import { FlipID, FlipHtmlElement } from './useFlip' 2 | 3 | export interface FlipKeyframeEffectOptions extends KeyframeEffectOptions { 4 | staggerStep?: number 5 | stagger?: number 6 | } 7 | 8 | export function debounce any>( 9 | cb: F, 10 | wait: number 11 | ) { 12 | let timer: any 13 | return function _debounce(...args: Parameters) { 14 | clearTimeout(timer) 15 | timer = setTimeout(() => cb(...args), wait) 16 | } 17 | } 18 | 19 | export const isRunning = (animation: Animation) => 20 | animation.playState === 'running' 21 | 22 | export const isPaused = (animation: Animation) => 23 | animation.playState === 'paused' 24 | 25 | export const not = (bool: boolean) => !bool 26 | export const emptyMap = (map: Map) => map.size === 0 27 | 28 | export const getRect = (element: Element) => element.getBoundingClientRect() 29 | 30 | export const getFlipId = (el: Element & FlipHtmlElement) => el.dataset.flipId 31 | 32 | export const getComputedBgColor = (element: Element) => 33 | getComputedStyle(element).getPropertyValue('background-color') 34 | 35 | export const getElementByFlipId = (flipId: FlipID) => 36 | document.querySelector(`[data-flip-id=${flipId}]`) as FlipHtmlElement 37 | 38 | export const getElementsByRootId = (rootId: string) => 39 | document.querySelectorAll(`[data-flip-root-id=${rootId}]`) 40 | 41 | export const getTranslateX = (cachedRect: DOMRect, nextRect: DOMRect) => 42 | cachedRect.x - nextRect.x 43 | 44 | export const getTranslateY = (cachedRect: DOMRect, nextRect: DOMRect) => 45 | cachedRect.y - nextRect.y 46 | 47 | export const getScaleX = ( 48 | cachedRect: DOMRect | ClientRect, 49 | nextRect: DOMRect | ClientRect 50 | ) => cachedRect.width / Math.max(nextRect.width, 0.001) 51 | 52 | export const getScaleY = ( 53 | cachedRect: DOMRect | ClientRect, 54 | nextRect: DOMRect | ClientRect 55 | ) => cachedRect.height / Math.max(nextRect.height, 0.001) 56 | 57 | export const createAnimation = ( 58 | element: FlipHtmlElement, 59 | keyframes: Keyframe[], 60 | options: FlipKeyframeEffectOptions 61 | ) => { 62 | const { duration, delay = 0, stagger = 0, staggerStep = 0 } = options 63 | const effect = new KeyframeEffect(element, keyframes, { 64 | duration, 65 | easing: 'linear', 66 | delay: delay + stagger * staggerStep, 67 | fill: 'both' 68 | }) 69 | 70 | // TODO: figure out what to do when position must be updated after animation 71 | // e.g. class has actually changed 72 | return new Animation(effect, document.timeline) 73 | } 74 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { useFlip, FlipContext, FlipProvider } from './useFlip' 2 | export { AnimateInOut } from './AnimateInOut' 3 | export * from './easings' 4 | -------------------------------------------------------------------------------- /src/keyframes.ts: -------------------------------------------------------------------------------- 1 | export const fadeOut = { 2 | from: { opacity: 1, transform: 'scale(1,1)' }, 3 | to: { opacity: 0, transform: 'scale(0,0)' }, 4 | duration: 500, 5 | easing: 'ease-out' 6 | } 7 | 8 | export const fadeIn = { 9 | from: { opacity: 0, transform: 'scale(0,0)' }, 10 | to: { opacity: 1, transform: 'scale(1,1)' }, 11 | duration: 500, 12 | easing: 'ease-out' 13 | } 14 | -------------------------------------------------------------------------------- /src/syncLayout.ts: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect } from './useLayoutEffect' 2 | 3 | type Callback = () => void 4 | 5 | type LayoutStep = 'prewrite' | 'read' | 'render' 6 | 7 | type CallbackLists = { 8 | prewrite: Callback[] 9 | read: Callback[] 10 | render: Callback[] 11 | } 12 | 13 | const jobs: CallbackLists = { 14 | prewrite: [], 15 | read: [], 16 | render: [] 17 | } 18 | 19 | const flushCallbackList = (jobs: Callback[]) => { 20 | for (const job of jobs) { 21 | job() 22 | } 23 | 24 | jobs.length = 0 25 | } 26 | 27 | const flushAllJobs = () => { 28 | flushCallbackList(jobs.prewrite) 29 | flushCallbackList(jobs.read) 30 | flushCallbackList(jobs.render) 31 | } 32 | 33 | const registerSyncCallback = (stepName: LayoutStep) => ( 34 | callback?: Callback 35 | ) => { 36 | if (!callback) return 37 | 38 | jobs[stepName].push(callback) 39 | } 40 | 41 | export const syncLayout = { 42 | prewrite: registerSyncCallback('prewrite'), 43 | read: registerSyncCallback('read'), 44 | render: registerSyncCallback('render'), 45 | flush: flushAllJobs, 46 | jobLength: () => [jobs.prewrite.length, jobs.read.length, jobs.render.length] 47 | } 48 | 49 | export function useSyncLayout() { 50 | return useLayoutEffect(flushAllJobs) 51 | } 52 | -------------------------------------------------------------------------------- /src/useFlip.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { useLayoutEffect } from './useLayoutEffect' 3 | import { FlipProvider, FlipContext } from './FlipProvider' 4 | import { 5 | isRunning, 6 | getElementByFlipId, 7 | emptyMap, 8 | not, 9 | getElementsByRootId, 10 | getComputedBgColor, 11 | getTranslateY, 12 | getTranslateX, 13 | getScaleX, 14 | getScaleY, 15 | getRect, 16 | createAnimation 17 | } from './helpers' 18 | import { DEFAULT_DURATION, DEFAULT_DELAY, DEFAULT_EASING } from './const' 19 | import { createKeyframes } from './createKeyframes' 20 | import { syncLayout, useSyncLayout } from './syncLayout' 21 | 22 | export { FlipProvider, FlipContext } 23 | 24 | export type FlipID = string 25 | 26 | export interface AnimationOptions { 27 | duration?: number 28 | easing?: (x: number) => number 29 | delay?: number 30 | animateColor?: boolean 31 | } 32 | 33 | export interface FlipHtmlElement extends Element { 34 | dataset: { 35 | flipId: FlipID 36 | } 37 | } 38 | 39 | type Transforms = Map< 40 | FlipID, 41 | { 42 | elm: FlipHtmlElement 43 | kfs: any 44 | } 45 | > 46 | 47 | export const useFlip = ( 48 | rootId: string, 49 | options: AnimationOptions = {}, 50 | deps?: any 51 | ) => { 52 | const { 53 | cachedAnimations, 54 | cachedStyles, 55 | pauseAll, 56 | resumeAll 57 | } = React.useContext(FlipContext) 58 | const transforms = React.useRef(new Map()).current 59 | 60 | const { 61 | delay = DEFAULT_DELAY, 62 | duration = DEFAULT_DURATION, 63 | easing = DEFAULT_EASING, 64 | animateColor = false 65 | } = options 66 | 67 | // If render happened during animation, do not wait for useLayoutEffect 68 | // and finish all animations, but cache their midflight position for next animation. 69 | // getBoundingClientRect will return correct values only here and not in useLayoutEffect! 70 | for (const flipId of cachedStyles.keys()) { 71 | const element = getElementByFlipId(flipId) 72 | 73 | if (not(emptyMap(cachedAnimations)) && element) { 74 | const cachedAnimation = cachedAnimations.get(flipId) 75 | 76 | if (cachedAnimation && isRunning(cachedAnimation)) { 77 | const v = cachedStyles.get(flipId) 78 | if (v) { 79 | syncLayout.prewrite(() => { 80 | cachedStyles.set(flipId, { 81 | rect: getRect(element), 82 | styles: { 83 | bgColor: getComputedBgColor(getElementByFlipId(flipId)) 84 | } 85 | }) 86 | }) 87 | syncLayout.render(() => { 88 | cachedAnimation.finish() 89 | }) 90 | } 91 | } 92 | } 93 | } 94 | 95 | syncLayout.flush() 96 | 97 | React.useEffect(() => { 98 | // Cache element positions on initial render for subsequent calculations 99 | for (const root of getElementsByRootId(rootId)) { 100 | // Select all root children that are supposed to be animated 101 | const flippableElements = root.querySelectorAll(`[data-flip-id]`) 102 | 103 | for (const element of flippableElements) { 104 | const { flipId } = (element as FlipHtmlElement).dataset 105 | 106 | cachedStyles.set(flipId, { 107 | styles: { 108 | bgColor: getComputedBgColor(element) 109 | }, 110 | rect: getRect(element) 111 | }) 112 | } 113 | } 114 | }, [rootId, deps, cachedStyles]) 115 | 116 | useLayoutEffect(() => { 117 | // Do not do anything on initial render 118 | if (emptyMap(cachedStyles)) return 119 | 120 | const cachedStyleEntries = cachedStyles.entries() 121 | 122 | for (const [flipId, value] of cachedStyleEntries) { 123 | const { rect: cachedRect, styles } = value 124 | 125 | // Select by data-flip-id which makes it possible to animate the element 126 | // that re-mounted in some other DOM location (i.e. shared layout transition) 127 | const flipElement = getElementByFlipId(flipId) 128 | 129 | if (flipElement) { 130 | syncLayout.read(() => { 131 | const nextRect = getRect(flipElement) 132 | 133 | const translateY = getTranslateY( 134 | cachedRect as DOMRect, 135 | nextRect as DOMRect 136 | ) 137 | const translateX = getTranslateX( 138 | cachedRect as DOMRect, 139 | nextRect as DOMRect 140 | ) 141 | const scaleX = getScaleX(cachedRect, nextRect) 142 | const scaleY = getScaleY(cachedRect, nextRect) 143 | 144 | // Update the cached position 145 | cachedStyles.get(flipId)!.rect = nextRect 146 | 147 | const nextColor = getComputedBgColor(flipElement) 148 | 149 | // Do not animate if there is no need to 150 | if ( 151 | translateX === 0 && 152 | translateY === 0 && 153 | scaleX === 1 && 154 | scaleY === 1 155 | ) { 156 | return 157 | } 158 | 159 | const kfs = createKeyframes({ 160 | sx: scaleX, 161 | sy: scaleY, 162 | dx: translateX, 163 | dy: translateY, 164 | easeFn: easing, 165 | calculateInverse: true 166 | }) 167 | 168 | if (animateColor) { 169 | kfs.animations[0].background = styles.bgColor 170 | kfs.animations[20].background = nextColor 171 | } 172 | 173 | transforms.set(flipId, { 174 | elm: flipElement, 175 | kfs: kfs.animations 176 | }) 177 | 178 | // Cache the color value 179 | styles.bgColor = nextColor 180 | }) 181 | } 182 | } 183 | 184 | const animationOptions = { 185 | duration, 186 | easing: 'linear', 187 | delay: delay, 188 | fill: 'both' as 'both' 189 | } 190 | 191 | for (const flipId of cachedStyles.keys()) { 192 | syncLayout.render(() => { 193 | const transform = transforms.get(flipId) 194 | 195 | if (!transform) return 196 | 197 | const animation = createAnimation( 198 | transform.elm, 199 | transform.kfs, 200 | animationOptions 201 | ) 202 | 203 | cachedAnimations.set(flipId, animation) 204 | transforms.delete(flipId) 205 | 206 | animation.play() 207 | }) 208 | } 209 | }) 210 | 211 | useSyncLayout() 212 | 213 | return { pause: pauseAll, resume: resumeAll } 214 | } 215 | -------------------------------------------------------------------------------- /src/useLayoutEffect.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | declare const window: any 4 | 5 | export const useLayoutEffect = 6 | typeof window !== 'undefined' && 7 | window.document && 8 | window.document.createElement 9 | ? React.useLayoutEffect 10 | : React.useEffect 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "esModuleInterop": true, 7 | "allowSyntheticDefaultImports": true, 8 | "strict": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "module": "esnext", 11 | "moduleResolution": "node", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react", 16 | "lib": ["dom.iterable", "es5", "es6", "esnext", "dom"], 17 | "downlevelIteration": true 18 | }, 19 | "include": ["src"] 20 | } 21 | --------------------------------------------------------------------------------