├── .babelrc ├── .eslintrc.js ├── .gitignore ├── .npmignore ├── .prettierrc.js ├── .travis.yml ├── LICENSE ├── README.md ├── gif ├── bouncing-ball.gif ├── gradients.gif ├── notifications.gif ├── observer.gif ├── rotating-circles.gif ├── rotating-svg.gif ├── svg-path.gif └── transition.gif ├── package-lock.json ├── package.json ├── site ├── assets │ ├── pause-button.svg │ ├── play-button.svg │ ├── react-tweenful.svg │ └── refresh-button.svg ├── components │ ├── App.js │ ├── Header.js │ └── Links.js ├── demo │ ├── BouncingBalls.js │ ├── Gradients.js │ ├── LoadingCircles.js │ ├── NotificationsDemo.js │ ├── ObserverDemo.js │ ├── RotatingSvg.js │ ├── RouteTransitionDemo.js │ └── SvgDemo.js ├── highlight │ └── index.js ├── html │ ├── 404.html │ └── index.html ├── index.js └── style │ ├── _buttons.scss │ ├── _normalize.scss │ ├── demo.scss │ ├── global.scss │ └── reset.css ├── src ├── Observer.js ├── ObserverGroup.js ├── SVG.js ├── Tweenful.js ├── easings │ └── index.js ├── engine │ ├── index.js │ └── utils.js ├── helpers │ ├── constants.js │ ├── index.js │ ├── svg-utils.js │ └── utils.js ├── index.js └── parser │ └── index.js ├── webpack.dev.js ├── webpack.prod.js ├── webpack.site.dev.js └── webpack.site.prod.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-react" 5 | ], 6 | "plugins": [ 7 | "transform-class-properties", 8 | [ 9 | "module-resolver", 10 | { 11 | "root": ["./"], 12 | "alias": { 13 | "src": "./src", 14 | "site": "./site" 15 | } 16 | } 17 | ] 18 | ] 19 | } -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | module.exports = { 4 | "parser": "babel-eslint", 5 | "parserOptions": { 6 | "ecmaVersion": 6, 7 | "sourceType": "module" 8 | }, 9 | "globals": { 10 | "process": true 11 | }, 12 | "settings": { 13 | "react": { 14 | version: "16.4.2" 15 | }, 16 | "import/resolver": { 17 | alias: { 18 | map: [ 19 | ["src", path.resolve(__dirname, "src")], 20 | ["site", path.resolve(__dirname, "site")] 21 | ], 22 | extensions: [".js", ".css", ".scss"] 23 | } 24 | } 25 | }, 26 | "extends": [ 27 | "eslint:recommended", 28 | "plugin:react/recommended" 29 | ], 30 | "env": { 31 | "es6": true, 32 | "browser": true, 33 | "jest": true 34 | }, 35 | "plugins": [ 36 | "react" 37 | ], 38 | "rules": { 39 | "quotes": "off", 40 | "linebreak-style": "off", 41 | "comma-dangle": "off", 42 | "react/prop-types": "off", 43 | "react/no-find-dom-node": "off", 44 | "react/jsx-uses-vars": "error", 45 | "no-constant-condition": "off", 46 | "no-console": ["error", { 47 | allow: ["warn", "error", "trace", "log"] 48 | }] 49 | } 50 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | dist/ 4 | .DS_Store -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | gif/ 3 | node_modules/ 4 | site/ 5 | src/ 6 | tests/ 7 | build/ 8 | .babelrc 9 | .gitignore 10 | .eslintrc.js 11 | .editorconfig 12 | jest.config.js 13 | webpack.*.js 14 | *.log 15 | .prettierrc.js 16 | github-preview.gif 17 | .travis.yml -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 100, 3 | trailingComma: 'none', 4 | useTabs: false, 5 | tabWidth: 2, 6 | semi: true, 7 | singleQuote: true, 8 | quoteProps: 'consistent', 9 | jsxBracketSameLine: false 10 | }; -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 11.1.0 3 | branches: 4 | only: 5 | - master 6 | install: 7 | - npm install 8 | script: 9 | - npm run build -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Rares Mardare 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 | [![npm version](https://badgen.net/npm/v/react-tweenful)](https://www.npmjs.com/package/react-tweenful) [![Minified & Gzipped size](https://badgen.net/bundlephobia/minzip/react-tweenful)](https://bundlephobia.com/result?p=react-ttweenful) 2 | 3 | # react-tweenful 4 | 5 | Looking for an amazing React library for animating stuff? Look no more, we've got you covered! 6 | 7 | ## Demo 8 | 9 | https://teodosii.github.io/react-tweenful/ 10 | 11 | ## Features 12 | 13 | * Loop support (infinite or up to a specific number) 14 | * Wide variety of easings (bezier, predefined and custom easing) 15 | * Delayed animations (before and after) 16 | * Events support 17 | * Negative delay support to mimic CSS animations 18 | * Percent based animations to mimic CSS animations (e.g. `0%`, `50%`, `100%`) 19 | * `Tweenful` component for animating DOM nodes 20 | * `SVG` component to animate SVG nodes 21 | * `Observer` component for mount/unmount animations 22 | * `ObserverGroup` component to handle child transition (list removal/insertion, page transition etc) 23 | 24 | ## Getting started 25 | 26 | ``` 27 | npm install react-tweenful 28 | ``` 29 | 30 | ### Development 31 | 32 | In order to build the project and run it on your local machine, you'll need to build both the `site` project and the library itself. The `site` project will have a dependency of `react-tweenful` and there you'll be able to play with the examples. 33 | 34 | ``` 35 | npm run build:library:dev 36 | npm run start 37 | ``` 38 | 39 | Or if you want to be able to modify both projects at once and let webpack watch the changes and do the build for you, then open 2 separate terminals and run the following commands 40 | 41 | ``` 42 | npm run watch:library:dev 43 | ``` 44 | And in a separate terminal run 45 | ``` 46 | npm run start 47 | ``` 48 | 49 | Both commands will instruct webpack to watch for changes. 50 | Navigate to `http://localhost:8080/react-tweenful` or whatever port you're running the application on. 51 | 52 | ## Usage 53 | 54 | `react-tweenful` exports the following: 55 | 56 | * `Tweenful` - component to animate DOM elements. It requires a DOM node to perform animation on. 57 | * `SVG` - component to animate SVG elements. It requires a SVG node to perform animation on. 58 | * `Observer` - component to animate mounting and unmounting of an element. 59 | * `ObserverGroup` - component to watch over a list of `Observer` elements such as list removal/insertion or route transition 60 | 61 | A couple of utility functions are also exported to help you out animating: 62 | 63 | * `percentage` for percentage based animations 64 | * `bezier` for bezier easings 65 | * `elastic` for elastic easing 66 | 67 | Import the needed component, for example `Tweenful` 68 | 69 | ```jsx 70 | import Tweenful, { elastic } from 'react-tweenful'; 71 | ``` 72 | 73 | `Tweenful` requires a node to render on which it will perform the animation. We've got most of the DOM nodes covered in the form of namespacing such as `Tweenful.div`, `Tweenful.span` and so on. 74 | 75 | ```jsx 76 | const Example = () => ( 77 | 84 | ); 85 | ``` 86 | 87 | Voila! 88 | 89 | ## Examples 90 | 91 | ### Observer 92 | 93 | View animation [here](https://teodosii.github.io/react-tweenful/observer) 94 | 95 | Usage of `Observer` component together with `Tweenful`. Animate movement with `Tweenful` and subscribe to mount and unmount with `Observer`. 96 | 97 | ![Observer](https://github.com/teodosii/react-tweenful/raw/master/gif/observer.gif "Observer") 98 | 99 | ```jsx 100 | import React, { useEffect, useState } from 'react'; 101 | import Tweenful, { Observer, elastic } from 'react-tweenful'; 102 | 103 | const props = { 104 | delay: 200, 105 | render: true, 106 | duration: 1600, 107 | easing: elastic(1, 0.1), 108 | loop: false, 109 | animate: { translateX: '400px' }, 110 | events: { 111 | onAnimationStart: () => console.log('AnimationStart'), 112 | onAnimationEnd: () => console.log('AnimationEnd') 113 | } 114 | }; 115 | 116 | const ObserverDemo = () => { 117 | const [shouldRender, setShouldRender] = useState(true); 118 | 119 | useEffect(() => { 120 | setTimeout(() => setShouldRender(false), 3000); 121 | }, []); 122 | 123 | return ( 124 | 132 |
133 | 134 |
135 |
136 | ); 137 | }; 138 | ``` 139 | 140 | ### ObserverGroup 141 | 142 | View animation [here](https://teodosii.github.io/react-tweenful/notifications) 143 | 144 | Usage of `ObserverGroup` component to watch for mounting and unmounting over a list of notifications 145 | 146 | ![Notifications](https://github.com/teodosii/react-tweenful/raw/master/gif/notifications.gif "Notifications") 147 | 148 | ```jsx 149 | 159 | {this.state.notifications.map(notification => ( 160 | 165 | ))} 166 | 167 | ``` 168 | 169 | ### Animate route transition 170 | 171 | View animation [here](https://teodosii.github.io/react-tweenful/transition) 172 | 173 | Usage of `ObserverGroup` to animate route change 174 | 175 | ![Routing](https://github.com/teodosii/react-tweenful/raw/master/gif/transition.gif "Routing") 176 | 177 | ```jsx 178 | import React from 'react'; 179 | import { Route, Switch, NavLink } from 'react-router-dom'; 180 | import ObserverGroup from 'react-tweenful/ObserverGroup'; 181 | import Observer from 'react-tweenful/Observer'; 182 | 183 | const colors = { 184 | red: '#EE4266', 185 | yellow: '#FDB833', 186 | blue: '#296EB4', 187 | green: '#0EAD69' 188 | }; 189 | 190 | const Red = () =>
; 191 | const Yellow = () =>
; 192 | const Blue = () =>
; 193 | const Green = () =>
; 194 | 195 | class RouteTransitionDemo extends React.Component { 196 | constructor(props) { 197 | super(props); 198 | } 199 | 200 | render() { 201 | const { location } = this.props; 202 | 203 | return ( 204 |
205 | 211 |
212 | 213 | 222 | 223 | 224 | 225 | 226 | 227 |
Not Found
} /> 228 |
229 |
230 |
231 |
232 |
233 | ); 234 | } 235 | } 236 | 237 | export default RouteTransitionDemo; 238 | 239 | ``` 240 | 241 | ### Prism 242 | 243 | View animation [here](https://teodosii.github.io/react-tweenful/rotating-svg) 244 | 245 | `react-tweenful` in action powering a super awesome looking animation. 246 | 247 | ![Prism](https://github.com/teodosii/react-tweenful/raw/master/gif/rotating-svg.gif "Prism") 248 | 249 | ```jsx 250 | import React from 'react'; 251 | import { SVG } from 'react-tweenful'; 252 | 253 | const WAVE_COUNT = 16; 254 | const offset = 40; 255 | const waveLength = 375; 256 | const duration = 1500; 257 | 258 | const waves = new Array(WAVE_COUNT).fill(0).map((wave, i) => ({ 259 | key: i + 1, 260 | style: { 261 | transformOrigin: '500px 500px', 262 | opacity: 4 / WAVE_COUNT, 263 | mixBlendMode: 'screen', 264 | fill: `hsl(${(360 / WAVE_COUNT) * (i + 1)}, 100%, 50%)`, 265 | transform: `rotate(${(360 / WAVE_COUNT) * i}deg) translate(${waveLength}px, ${offset}px)` 266 | }, 267 | rotate: `${(360 / WAVE_COUNT) * (i + 1)}deg`, 268 | translate: `${waveLength}px ${offset}px`, 269 | angle: `${(360 / WAVE_COUNT) * (i + 1)}deg`, 270 | delay: (duration / WAVE_COUNT) * (i + 1) * -3, 271 | path: 272 | 'M-1000,1000V388c86-2,111-38,187-38s108,38,187,38,111-38,187-38,108,38,187,38,111-38,187-38,108,38,187,38,111-38,187-38,109,38,188,38,110-38,187-38,108,38,187,38,111-38,187-38,108,38,187,38,111-38,187-38,108,38,187,38,111-38,187-38,109,38,188,38c0,96,0,612,0,612Z' 273 | })); 274 | 275 | const RotatingSvg = () => { 276 | return ( 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 293 | {waves.map(wave => ( 294 | 306 | ))} 307 | 308 | 309 | ); 310 | }; 311 | ``` 312 | 313 | ### SVG 314 | 315 | View animation [here](https://teodosii.github.io/react-tweenful/svg) 316 | 317 | An example animating SVG `path` and `fill` properties one after the other. 318 | 319 | ![SVG](https://github.com/teodosii/react-tweenful/raw/master/gif/svg-path.gif "SVG") 320 | 321 | ### Gradients 322 | 323 | View animation [here](https://teodosii.github.io/react-tweenful/gradients) 324 | 325 | An example using `elastic` easing to animate gradient boxes. 326 | 327 | ![Gradients](https://github.com/teodosii/react-tweenful/raw/master/gif/gradients.gif "Gradients") 328 | 329 | ```jsx 330 | import React from 'react'; 331 | import Tweenful, { elastic } from 'react-tweenful'; 332 | 333 | const Gradients = () => { 334 | const elements = new Array(10) 335 | .fill(0) 336 | .map((_e, i) => ( 337 | 347 | )); 348 | 349 | return ( 350 |
351 |
{elements}
352 |
353 | ); 354 | }; 355 | ``` 356 | 357 | ### Loader 358 | 359 | View animation [here](https://teodosii.github.io/react-tweenful/loading-circles) 360 | 361 | ![Loader](https://github.com/teodosii/react-tweenful/raw/master/gif/rotating-circles.gif "Loader") 362 | 363 | ```jsx 364 | import React from 'react'; 365 | import Tweenful, { percentage } from 'react-tweenful'; 366 | 367 | const rotate = percentage({ 368 | '0%': { translate: '-50% -50%', rotate: '0deg' }, 369 | '50%': { translate: '-50% -50%', rotate: '0deg' }, 370 | '80%': { translate: '-50% -50%', rotate: '360deg' }, 371 | '100%': { translate: '-50% -50%', rotate: '360deg' } 372 | }); 373 | const dot1Animate = percentage({ 374 | '0%': { scale: 1 }, 375 | '20%': { scale: 1 }, 376 | '45%': { translate: '16px 12px', scale: 0.45 }, 377 | '60%': { translate: '160px 150px', scale: 0.45 }, 378 | '80%': { translate: '160px 150px', scale: 0.45 }, 379 | '100%': { translate: '0px 0px', scale: 1 } 380 | }); 381 | const dot2Animate = percentage({ 382 | '0%': { scale: 1 }, 383 | '20%': { scale: 1 }, 384 | '45%': { translate: '-16px 12px', scale: 0.45 }, 385 | '60%': { translate: '-160px 150px', scale: 0.45 }, 386 | '80%': { translate: '-160px 150px', scale: 0.45 }, 387 | '100%': { translate: '0px 0px', scale: 1 } 388 | }); 389 | const dot3Animate = percentage({ 390 | '0%': { scale: 1 }, 391 | '20%': { scale: 1 }, 392 | '45%': { translateY: '-18px', scale: 0.45 }, 393 | '60%': { translateY: '-180px', scale: 0.45 }, 394 | '80%': { translateY: '-180px', scale: 0.45 }, 395 | '100%': { translateY: '0px', scale: 1 } 396 | }); 397 | 398 | const LoadingCircles = () => { 399 | return ( 400 |
401 | 409 | 417 | 425 | 433 | 434 | 435 | 436 | 437 | 438 | 439 | 444 | 445 | 446 | 447 |
448 | ); 449 | }; 450 | 451 | ``` 452 | 453 | ### Bouncing Balls 454 | 455 | View animation [here](https://teodosii.github.io/react-tweenful/bouncing-balls) 456 | 457 | Bouncing balls illustrate negative delay support in `react-tweenful` 458 | 459 | ![Bouncing Balls](https://github.com/teodosii/react-tweenful/raw/master/gif/bouncing-ball.gif "Bouncing Balls") 460 | 461 | ```jsx 462 | import React from 'react'; 463 | import { SVG, percentage, elastic } from 'react-tweenful'; 464 | 465 | const circles = new Array(10).fill(0).map((_e, i) => ({ 466 | loop: true, 467 | fill: `hsl(${(i + 1) * 20 - 20}, 70%, 70%)`, 468 | delay: ((i + 1) * 1500) / -10, 469 | duration: 1500, 470 | easing: elastic(2, 0.9), 471 | transform: { 472 | translate: '0 100px' 473 | }, 474 | style: { 475 | transformOrigin: `${-200 + 120 * (i + 1)}px 250px` 476 | }, 477 | animate: percentage({ 478 | '0%': { translate: '0px 100px', scale: 1 }, 479 | '50%': { translate: '0px -100px', scale: 0.3 }, 480 | '100%': { translate: '0px 100px', scale: 1 } 481 | }), 482 | r: 35, 483 | cx: 100 * i + 50, 484 | cy: 250 485 | })); 486 | 487 | const BouncingBalls = () => { 488 | return ( 489 |
490 | 491 | {circles.map((circle, i) => ( 492 | 493 | ))} 494 | 495 |
496 | ); 497 | }; 498 | ``` 499 | 500 | ## API 501 | 502 | **Update in progress. Stay in touch!** 503 | 504 | ## Note 505 | 506 | A couple of the animations shown here have been initially created for CodePen by other folks. My only contribution to them was to convert them to `react-tweenful` to show real world examples on animating stuff. 507 | 508 | ## License 509 | 510 | MIT 511 | -------------------------------------------------------------------------------- /gif/bouncing-ball.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/teodosii/react-tweenful/2443c4a430dd73559d62e0a95e6f4c1a0fac6ccd/gif/bouncing-ball.gif -------------------------------------------------------------------------------- /gif/gradients.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/teodosii/react-tweenful/2443c4a430dd73559d62e0a95e6f4c1a0fac6ccd/gif/gradients.gif -------------------------------------------------------------------------------- /gif/notifications.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/teodosii/react-tweenful/2443c4a430dd73559d62e0a95e6f4c1a0fac6ccd/gif/notifications.gif -------------------------------------------------------------------------------- /gif/observer.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/teodosii/react-tweenful/2443c4a430dd73559d62e0a95e6f4c1a0fac6ccd/gif/observer.gif -------------------------------------------------------------------------------- /gif/rotating-circles.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/teodosii/react-tweenful/2443c4a430dd73559d62e0a95e6f4c1a0fac6ccd/gif/rotating-circles.gif -------------------------------------------------------------------------------- /gif/rotating-svg.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/teodosii/react-tweenful/2443c4a430dd73559d62e0a95e6f4c1a0fac6ccd/gif/rotating-svg.gif -------------------------------------------------------------------------------- /gif/svg-path.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/teodosii/react-tweenful/2443c4a430dd73559d62e0a95e6f4c1a0fac6ccd/gif/svg-path.gif -------------------------------------------------------------------------------- /gif/transition.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/teodosii/react-tweenful/2443c4a430dd73559d62e0a95e6f4c1a0fac6ccd/gif/transition.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-tweenful", 3 | "version": "0.1.0", 4 | "homepage": "http://teodosii.github.io/react-tweenful", 5 | "description": "Animation engine for React", 6 | "main": "dist/react-tweenful.js", 7 | "scripts": { 8 | "build": "npm run build:library:prod", 9 | "build:library:dev": "webpack --config webpack.dev.js", 10 | "build:library:prod": "webpack --config webpack.prod.js", 11 | "build:site:dev": "webpack --config webpack.site.dev.js", 12 | "build:site:prod": "webpack --config webpack.site.prod.js", 13 | "watch:library": "webpack -w --config webpack.dev.js", 14 | "start": "webpack-dev-server --config ./webpack.site.dev.js", 15 | "predeploy": "npm run build:site:prod", 16 | "deploy": "gh-pages -d dist", 17 | "publish:npm": "npm run build && npm publish" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/teodosii/react-tweenful" 22 | }, 23 | "bugs": { 24 | "url": "https://github.com/teodosii/react-tweenful/issues" 25 | }, 26 | "keywords": [ 27 | "react", 28 | "react-animation", 29 | "react-animation-component", 30 | "react-tween", 31 | "animation", 32 | "animation-engine", 33 | "react-component", 34 | "tween" 35 | ], 36 | "author": "Rares Mardare", 37 | "license": "MIT", 38 | "devDependencies": { 39 | "@babel/core": "^7.8.3", 40 | "@babel/polyfill": "^7.8.3", 41 | "@babel/preset-env": "^7.8.3", 42 | "@babel/preset-react": "^7.8.3", 43 | "@babel/runtime": "^7.8.3", 44 | "babel-eslint": "^10.0.1", 45 | "babel-jest": "^25.1.0", 46 | "babel-loader": "^8.0.1", 47 | "babel-plugin-module-resolver": "^4.0.0", 48 | "babel-plugin-transform-class-properties": "^6.24.1", 49 | "clean-webpack-plugin": "^3.0.0", 50 | "copy-webpack-plugin": "^5.1.1", 51 | "css-loader": "^3.4.2", 52 | "css-select-base-adapter": "^0.1.1", 53 | "eslint": "^6.8.0", 54 | "eslint-config-airbnb": "^18.0.1", 55 | "eslint-config-standard": "^14.1.0", 56 | "eslint-import-resolver-alias": "^1.1.0", 57 | "eslint-loader": "^3.0.3", 58 | "eslint-plugin-import": "^2.20.0", 59 | "eslint-plugin-jsx-a11y": "^6.1.0", 60 | "eslint-plugin-node": "^11.0.0", 61 | "eslint-plugin-promise": "^4.2.1", 62 | "eslint-plugin-react": "^7.18.0", 63 | "eslint-plugin-react-hooks": "^2.3.0", 64 | "eslint-plugin-standard": "^4.0.1", 65 | "extract-text-webpack-plugin": "^3.0.2", 66 | "file-loader": "^5.0.2", 67 | "font-awesome": "^4.7.0", 68 | "gh-pages": "^2.0.1", 69 | "html-webpack-plugin": "^3.2.0", 70 | "mini-css-extract-plugin": "^0.9.0", 71 | "node-sass": "^4.13.1", 72 | "object-assign": "^4.1.1", 73 | "optimize-css-assets-webpack-plugin": "^5.0.3", 74 | "prettier": "^1.18.2", 75 | "react": "^16.12.0", 76 | "react-addons-create-fragment": "^15.6.2", 77 | "react-dom": "^16.12.0", 78 | "react-github-button": "^0.1.11", 79 | "react-github-corner": "^2.3.0", 80 | "react-router": "^5.1.2", 81 | "react-router-dom": "^5.1.2", 82 | "react-syntax-highlighter": "^12.2.1", 83 | "react-test-renderer": "^16.12.0", 84 | "regenerator-runtime": "^0.13.1", 85 | "sass-loader": "^8.0.2", 86 | "style-loader": "^1.1.3", 87 | "terser-webpack-plugin": "^2.3.5", 88 | "url-loader": "^3.0.0", 89 | "webpack": "^4.41.5", 90 | "webpack-cli": "^3.3.10", 91 | "webpack-dev-server": "^3.10.1" 92 | }, 93 | "peerDependencies": { 94 | "react": "^16.0.0" 95 | }, 96 | "dependencies": { 97 | "bezier-easing": "^2.1.0", 98 | "color-parser": "^0.1.0", 99 | "prop-types": "^15.6.2" 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /site/assets/pause-button.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 7 | 8 | 10 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /site/assets/play-button.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /site/assets/react-tweenful.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /site/assets/refresh-button.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 7 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /site/components/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'; 3 | import Header from 'site/components/Header'; 4 | import Links from 'site/components/Links'; 5 | import ObserverDemo from 'site/demo/ObserverDemo'; 6 | import SvgDemo from 'site/demo/SvgDemo'; 7 | import LoadingCircles from 'site/demo/LoadingCircles'; 8 | import RouteTransitionDemo from 'site/demo/RouteTransitionDemo'; 9 | import NotificationsDemo from 'site/demo/NotificationsDemo'; 10 | import RotatingSvg from 'site/demo/RotatingSvg'; 11 | import Gradients from 'site/demo/Gradients'; 12 | import BouncingBalls from 'site/demo/BouncingBalls'; 13 | import 'site/style/demo.scss'; 14 | 15 | const App = () => { 16 | return ( 17 | 18 | 19 | 20 |
21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | ); 34 | }; 35 | 36 | export default App; 37 | -------------------------------------------------------------------------------- /site/components/Header.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Header = () => { 4 | return ( 5 |
6 |
7 |

react-tweenful

8 | GitHub 9 |
10 |
11 | ); 12 | }; 13 | 14 | export default Header; -------------------------------------------------------------------------------- /site/components/Links.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | 4 | const Links = () => { 5 | return ( 6 |
7 |

Navigate to an example

8 |
    9 |
  • 10 | Notifications 11 |
  • 12 |
  • 13 | Route transition 14 |
  • 15 |
  • 16 | Observer 17 |
  • 18 |
  • 19 | SVG 20 |
  • 21 |
  • 22 | Loader 23 |
  • 24 |
  • 25 | Prism 26 |
  • 27 |
  • 28 | Gradients 29 |
  • 30 |
  • 31 | Bouncing Balls 32 |
  • 33 |
34 |
35 | ); 36 | }; 37 | 38 | export default Links; 39 | -------------------------------------------------------------------------------- /site/demo/BouncingBalls.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { SVG, percentage, elastic } from 'react-tweenful'; 3 | 4 | const circles = new Array(10).fill(0).map((_e, i) => ({ 5 | loop: true, 6 | fill: `hsl(${(i + 1) * 20 - 20}, 70%, 70%)`, 7 | delay: ((i + 1) * 1500) / -10, 8 | duration: 1500, 9 | easing: elastic(2, 0.9), 10 | transform: { 11 | translate: '0 100px' 12 | }, 13 | style: { 14 | transformOrigin: `${-200 + 120 * (i + 1)}px 250px` 15 | }, 16 | animate: percentage({ 17 | '0%': { translate: '0px 100px', scale: 1 }, 18 | '50%': { translate: '0px -100px', scale: 0.3 }, 19 | '100%': { translate: '0px 100px', scale: 1 } 20 | }), 21 | r: 35, 22 | cx: 100 * i + 50, 23 | cy: 250 24 | })); 25 | 26 | const BouncingBalls = () => { 27 | return ( 28 |
29 | 30 | {circles.map((circle, i) => ( 31 | 32 | ))} 33 | 34 |
35 | ); 36 | }; 37 | 38 | export default BouncingBalls; 39 | -------------------------------------------------------------------------------- /site/demo/Gradients.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Tweenful, { elastic } from 'react-tweenful'; 3 | 4 | const Gradients = () => { 5 | const elements = new Array(10) 6 | .fill(0) 7 | .map((_e, i) => ( 8 | 18 | )); 19 | 20 | return ( 21 |
22 |
{elements}
23 |
24 | ); 25 | }; 26 | 27 | export default Gradients; 28 | -------------------------------------------------------------------------------- /site/demo/LoadingCircles.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Tweenful, { percentage } from 'react-tweenful'; 3 | 4 | const rotate = percentage({ 5 | '0%': { translate: '-50% -50%', rotate: '0deg' }, 6 | '50%': { translate: '-50% -50%', rotate: '0deg' }, 7 | '80%': { translate: '-50% -50%', rotate: '360deg' }, 8 | '100%': { translate: '-50% -50%', rotate: '360deg' } 9 | }); 10 | const dot1Animate = percentage({ 11 | '0%': { scale: 1 }, 12 | '20%': { scale: 1 }, 13 | '45%': { translate: '16px 12px', scale: 0.45 }, 14 | '60%': { translate: '160px 150px', scale: 0.45 }, 15 | '80%': { translate: '160px 150px', scale: 0.45 }, 16 | '100%': { translate: '0px 0px', scale: 1 } 17 | }); 18 | const dot2Animate = percentage({ 19 | '0%': { scale: 1 }, 20 | '20%': { scale: 1 }, 21 | '45%': { translate: '-16px 12px', scale: 0.45 }, 22 | '60%': { translate: '-160px 150px', scale: 0.45 }, 23 | '80%': { translate: '-160px 150px', scale: 0.45 }, 24 | '100%': { translate: '0px 0px', scale: 1 } 25 | }); 26 | const dot3Animate = percentage({ 27 | '0%': { scale: 1 }, 28 | '20%': { scale: 1 }, 29 | '45%': { translateY: '-18px', scale: 0.45 }, 30 | '60%': { translateY: '-180px', scale: 0.45 }, 31 | '80%': { translateY: '-180px', scale: 0.45 }, 32 | '100%': { translateY: '0px', scale: 1 } 33 | }); 34 | 35 | const LoadingCircles = () => { 36 | return ( 37 |
38 | 46 | 54 | 62 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 81 | 82 | 83 | 84 |
85 | ); 86 | }; 87 | 88 | export default LoadingCircles; 89 | -------------------------------------------------------------------------------- /site/demo/NotificationsDemo.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ObserverGroup from 'react-tweenful/ObserverGroup'; 3 | 4 | const messages = [ 5 | 'All your data has been successfully updated', 6 | 'Your meeting has been successfully attended', 7 | 'Document has been successfully updated', 8 | 'You have no access rights', 9 | 'An error occurred while saving', 10 | 'Document has been permanently removed' 11 | ]; 12 | 13 | const types = ['default', 'success', 'danger']; 14 | const number = (start, end) => Math.floor(Math.random() * end) + start; 15 | 16 | const Notification = ({ notification, onClick, style }) => { 17 | const { id, message, type } = notification; 18 | 19 | return ( 20 |
21 |
onClick(id)} 23 | className={`notification-item notification-${type}`} 24 | > 25 |
26 |
27 |

{message}

28 |
29 |
30 |
31 | ); 32 | }; 33 | 34 | class Notifications extends React.Component { 35 | constructor(props) { 36 | super(props); 37 | 38 | this.state = { 39 | notifications: new Array(5).fill(0).map(() => { 40 | const id = number(1100, 9900); 41 | return { 42 | id, 43 | message: `${id} ${messages[number(0, messages.length)]}`, 44 | type: types[number(0, types.length)] 45 | }; 46 | }) 47 | }; 48 | 49 | this.removeNotification = this.removeNotification.bind(this); 50 | this.appendNotification = this.appendNotification.bind(this); 51 | } 52 | 53 | appendNotification() { 54 | const id = number(1100, 9900); 55 | this.setState(prevState => ({ 56 | notifications: [ 57 | ...prevState.notifications, 58 | { 59 | id, 60 | message: `${id} ${messages[number(0, messages.length - 1)]}`, 61 | type: types[number(0, types.length - 1)] 62 | } 63 | ] 64 | })); 65 | } 66 | 67 | removeNotification(id) { 68 | this.setState(prevState => ({ 69 | notifications: prevState.notifications.filter(notif => notif.id !== id) 70 | })); 71 | } 72 | 73 | render() { 74 | return ( 75 |
76 |
77 | 78 | Add 79 | 80 |
81 |
82 | 92 | {this.state.notifications.map(notification => ( 93 | 98 | ))} 99 | 100 |
101 |
102 | ); 103 | } 104 | } 105 | 106 | export default Notifications; 107 | -------------------------------------------------------------------------------- /site/demo/ObserverDemo.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import Tweenful, { Observer, elastic } from 'react-tweenful'; 3 | 4 | const props = { 5 | delay: 200, 6 | render: true, 7 | duration: 1600, 8 | easing: elastic(1, 0.1), 9 | loop: false, 10 | animate: { translateX: '400px' }, 11 | events: { 12 | onAnimationStart: () => console.log('AnimationStart'), 13 | onAnimationEnd: () => console.log('AnimationEnd') 14 | } 15 | }; 16 | 17 | const ObserverDemo = () => { 18 | const [shouldRender, setShouldRender] = useState(true); 19 | 20 | useEffect(() => { 21 | setTimeout(() => setShouldRender(false), 3000); 22 | }, []); 23 | 24 | return ( 25 | 33 |
34 | 35 |
36 |
37 | ); 38 | }; 39 | 40 | export default ObserverDemo; 41 | -------------------------------------------------------------------------------- /site/demo/RotatingSvg.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { SVG } from 'react-tweenful'; 3 | 4 | const WAVE_COUNT = 16; 5 | const offset = 40; 6 | const waveLength = 375; 7 | const duration = 1500; 8 | 9 | const waves = new Array(WAVE_COUNT).fill(0).map((wave, i) => ({ 10 | key: i + 1, 11 | style: { 12 | transformOrigin: '500px 500px', 13 | opacity: 4 / WAVE_COUNT, 14 | mixBlendMode: 'screen', 15 | fill: `hsl(${(360 / WAVE_COUNT) * (i + 1)}, 100%, 50%)`, 16 | transform: `rotate(${(360 / WAVE_COUNT) * i}deg) translate(${waveLength}px, ${offset}px)` 17 | }, 18 | rotate: `${(360 / WAVE_COUNT) * (i + 1)}deg`, 19 | translate: `${waveLength}px ${offset}px`, 20 | angle: `${(360 / WAVE_COUNT) * (i + 1)}deg`, 21 | delay: (duration / WAVE_COUNT) * (i + 1) * -3, 22 | path: 23 | 'M-1000,1000V388c86-2,111-38,187-38s108,38,187,38,111-38,187-38,108,38,187,38,111-38,187-38,108,38,187,38,111-38,187-38,109,38,188,38,110-38,187-38,108,38,187,38,111-38,187-38,108,38,187,38,111-38,187-38,108,38,187,38,111-38,187-38,109,38,188,38c0,96,0,612,0,612Z' 24 | })); 25 | 26 | const RotatingSvg = () => { 27 | return ( 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 44 | {waves.map(wave => ( 45 | 57 | ))} 58 | 59 | 60 | ); 61 | }; 62 | 63 | export default RotatingSvg; 64 | -------------------------------------------------------------------------------- /site/demo/RouteTransitionDemo.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Route, Switch, NavLink } from 'react-router-dom'; 3 | import ObserverGroup from 'react-tweenful/ObserverGroup'; 4 | import Observer from 'react-tweenful/Observer'; 5 | 6 | const colors = { 7 | red: '#EE4266', 8 | yellow: '#FDB833', 9 | blue: '#296EB4', 10 | green: '#0EAD69' 11 | }; 12 | 13 | const Red = () =>
; 14 | const Yellow = () =>
; 15 | const Blue = () =>
; 16 | const Green = () =>
; 17 | 18 | class RouteTransitionDemo extends React.Component { 19 | constructor(props) { 20 | super(props); 21 | } 22 | 23 | render() { 24 | const { location } = this.props; 25 | 26 | return ( 27 |
28 |
    29 |
  • Red
  • 30 |
  • Yellow
  • 31 |
  • Blue
  • 32 |
  • Green
  • 33 |
34 |
35 | 36 | console.log('onMountStart'), 45 | onUnmountEnd: () => console.log('onUnmountEnd') 46 | }} 47 | easing={'easeOutQuad'} 48 | > 49 | 50 | 51 | 52 | 53 | 54 |
Not Found
} /> 55 |
56 |
57 |
58 |
59 |
60 | ); 61 | } 62 | } 63 | 64 | export default RouteTransitionDemo; 65 | -------------------------------------------------------------------------------- /site/demo/SvgDemo.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import SVG from 'react-tweenful/SVG'; 3 | 4 | const SvgDemo = () => { 5 | return ( 6 | 13 | 23 | 24 | ); 25 | }; 26 | 27 | export default SvgDemo; -------------------------------------------------------------------------------- /site/highlight/index.js: -------------------------------------------------------------------------------- 1 | export default { 2 | demo: `import React from "react"; 3 | import uniquePropHOC from "./lib/unique-prop-hoc"; 4 | 5 | class Expire extends React.Component { 6 | constructor(props) { 7 | super(props); 8 | this.state = { component: props.children }; 9 | } 10 | componentDidMount() { 11 | setTimeout(() => { 12 | this.setState({ 13 | component: null 14 | }); 15 | }, this.props.time || this.props.seconds * 1000); 16 | } 17 | render() { 18 | return this.state.component; 19 | } 20 | } 21 | 22 | export default uniquePropHOC(["time", "seconds"])(Expire);` 23 | }; 24 | -------------------------------------------------------------------------------- /site/html/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 404 6 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /site/html/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | react-tweenful 8 | 9 | 44 | 45 | 46 | 47 |
48 | 49 | 50 | -------------------------------------------------------------------------------- /site/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { PrismLight as SyntaxHighlighter } from 'react-syntax-highlighter'; 4 | import jsx from 'react-syntax-highlighter/dist/esm/languages/prism/jsx'; 5 | import Application from './components/App'; 6 | import './style/global.scss'; 7 | 8 | window.addEventListener('DOMContentLoaded', () => { 9 | SyntaxHighlighter.registerLanguage('jsx', jsx); 10 | }); 11 | 12 | document.addEventListener('readystatechange', event => { 13 | if (event.target.readyState === "interactive") { 14 | // DOM accessible 15 | } 16 | 17 | if (event.target.readyState === "complete") { 18 | ReactDOM.render(, document.getElementsByClassName('app')[0]); 19 | } 20 | }); 21 | -------------------------------------------------------------------------------- /site/style/_buttons.scss: -------------------------------------------------------------------------------- 1 | .button { 2 | color: #fff; 3 | display: inline-flex; 4 | align-items: center; 5 | justify-content: center; 6 | vertical-align: middle; 7 | text-align: center; 8 | font-family: inherit; 9 | font-size: 0.9375em; 10 | text-decoration: none; 11 | text-transform: none; 12 | border-radius: 4px; 13 | background-color: #2355f3; 14 | border: 0 solid transparent; 15 | min-height: 2.7em; 16 | padding: 0.5em 1.6em; 17 | box-shadow: none; 18 | cursor: pointer; 19 | line-height: 1.2; 20 | 21 | &:hover { 22 | transition: all linear 0.2s; 23 | outline: 0; 24 | text-decoration: none; 25 | color: rgba(255, 255, 255, 0.95); 26 | background-color: #2355f3; 27 | } 28 | } 29 | 30 | a.primary, 31 | button.primary { 32 | font-size: 0.9375em; 33 | font-weight: 500; 34 | text-decoration: none; 35 | text-transform: none; 36 | min-height: 2.66667em; 37 | box-shadow: none; 38 | color: #0c3dd7; 39 | background: 0 0; 40 | border-radius: 4px; 41 | border: 2px solid #0c3dd7; 42 | padding: 0.5em 1.6em; 43 | 44 | &:hover { 45 | color: rgba(255, 255, 255, 0.95); 46 | background: #3060f9; 47 | border-color: #3060f9; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /site/style/_normalize.scss: -------------------------------------------------------------------------------- 1 | 2 | *, 3 | *::before, 4 | *::after { 5 | box-sizing: border-box; 6 | } 7 | 8 | h1, 9 | h2, 10 | h3, 11 | h4, 12 | h5, 13 | h6 { 14 | font-weight: bold; 15 | } 16 | 17 | h1 { 18 | font-size: 2.5em; 19 | } 20 | 21 | h2 { 22 | font-size: 1.75em; 23 | } 24 | 25 | h3 { 26 | font-size: 1.5em; 27 | } 28 | 29 | h4 { 30 | font-size: 1.25em; 31 | } 32 | 33 | h5, 34 | li li { 35 | font-size: 1em; 36 | } 37 | 38 | h6 { 39 | font-size: 0.75em; 40 | letter-spacing: 0.01em; 41 | } 42 | 43 | h1, 44 | h2, 45 | h3, 46 | h4, 47 | h5, 48 | h6, 49 | p, 50 | li { 51 | color: #333; 52 | line-height: 120%; 53 | } 54 | 55 | a { 56 | color: #158df7; 57 | text-decoration: none; 58 | .active, 59 | &:hover { 60 | color: #ee2455; 61 | } 62 | } -------------------------------------------------------------------------------- /site/style/demo.scss: -------------------------------------------------------------------------------- 1 | $default: #007bff; 2 | $default_dark: #0562c7; 3 | $success: #28a745; 4 | $success_dark: #1f8838; 5 | $danger: #dc3545; 6 | $danger_dark: #bd1120; 7 | 8 | .observer-demo { 9 | padding: 20px 30px; 10 | 11 | .box-demo { 12 | top: 0; 13 | left: 0; 14 | width: 100px; 15 | height: 100px; 16 | border-radius: 0%; 17 | background-color: #167fe0; 18 | position: relative; 19 | opacity: 1; 20 | } 21 | } 22 | 23 | .route-transition-demo { 24 | width: 100%; 25 | height: 100%; 26 | padding: 10px; 27 | 28 | .observer, 29 | .key-wrapper, 30 | .route-transition-demo, 31 | .color-block { 32 | width: 100%; 33 | height: 100%; 34 | position: relative; 35 | } 36 | 37 | .key-wrapper { 38 | z-index: 10; 39 | position: absolute; 40 | max-width: 400px; 41 | max-height: 600px; 42 | &:last-child { 43 | z-index: 100; 44 | } 45 | } 46 | 47 | .nav-links { 48 | list-style: none; 49 | margin-bottom: 10px; 50 | li { 51 | font-size: 18px; 52 | margin-right: 10px; 53 | display: inline-block; 54 | } 55 | } 56 | } 57 | 58 | .notifications-demo { 59 | width: 360px; 60 | margin: 0 auto; 61 | 62 | .actions { 63 | padding-top: 15px; 64 | padding-bottom: 15px; 65 | display: flex; 66 | justify-content: center; 67 | } 68 | 69 | .notification-content { 70 | padding: 8px 15px; 71 | display: inline-block; 72 | width: 100%; 73 | } 74 | 75 | .notification-default { 76 | background-color: $default; 77 | border-left: 8px solid $default_dark; 78 | .notification-close { 79 | background-color: $default; 80 | } 81 | } 82 | 83 | .notification-success { 84 | background-color: $success; 85 | border-left: 8px solid $success_dark; 86 | .notification-close { 87 | background-color: $success; 88 | } 89 | } 90 | 91 | .notification-danger { 92 | background-color: $danger; 93 | border-left: 8px solid $danger_dark; 94 | .notification-close { 95 | background-color: $danger; 96 | } 97 | } 98 | 99 | .notification-item { 100 | display: flex; 101 | position: relative; 102 | border-radius: 3px; 103 | margin-bottom: 15px; 104 | box-shadow: 1px 3px 4px rgba(0, 0, 0, 0.2); 105 | max-width: 100%; 106 | cursor: default; 107 | 108 | .notification-message { 109 | color: #fff; 110 | max-width: calc(100% - 15px); 111 | font-size: 14px; 112 | line-height: 150%; 113 | word-wrap: break-word; 114 | margin-bottom: 0; 115 | margin-top: 0; 116 | } 117 | 118 | .notification-close { 119 | width: 18px; 120 | height: 18px; 121 | border-radius: 50%; 122 | display: inline-block; 123 | position: absolute; 124 | right: 10px; 125 | top: 10px; 126 | 127 | &::after { 128 | content: '\D7'; 129 | cursor: pointer; 130 | position: absolute; 131 | transform: translate(-50%, -50%); 132 | color: #fff; 133 | font-size: 12px; 134 | left: 50%; 135 | top: 50%; 136 | } 137 | } 138 | } 139 | } 140 | 141 | .loading-wrapper { 142 | width: 100%; 143 | height: 100%; 144 | background-color: #f8f4d5; 145 | } 146 | 147 | .loading-circles-container { 148 | width: 400px; 149 | height: 400px; 150 | top: 50%; 151 | left: 50%; 152 | position: absolute; 153 | transform: translate(-50%, -50%); 154 | margin: auto; 155 | filter: url('#goo'); 156 | 157 | .dot { 158 | width: 70px; 159 | height: 70px; 160 | border-radius: 50%; 161 | background-color: #000; 162 | position: absolute; 163 | top: 0; 164 | bottom: 0; 165 | left: 0; 166 | right: 0; 167 | margin: auto; 168 | 169 | &.dot-1 { 170 | background-color: #ffe386; 171 | } 172 | &.dot-3 { 173 | background-color: #f74d75; 174 | } 175 | &.dot-2 { 176 | background-color: #10beae; 177 | } 178 | } 179 | } 180 | 181 | .gradients-container { 182 | height: 100%; 183 | 184 | .row { 185 | height: 100%; 186 | display: flex; 187 | flex-direction: row; 188 | padding-left: 10px; 189 | padding-right: 10px; 190 | padding-top: 10px; 191 | } 192 | 193 | .box { 194 | height: 500px; 195 | max-height: 60%; 196 | width: 10%; 197 | float: left; 198 | margin-left: 5px; 199 | margin-right: 5px; 200 | border-radius: 4px; 201 | } 202 | } 203 | 204 | .gradiant1 { 205 | background-image: linear-gradient(to bottom, #f8e739, #f1d42d, #eac222, #e1b019, #d89e10); 206 | } 207 | .gradiant2 { 208 | background-image: linear-gradient(to bottom, #d89e10, #e28f00, #ec7f00, #f66b00, #ff5200); 209 | } 210 | .gradiant3 { 211 | background-image: linear-gradient(to bottom, #ff5200, #f84603, #f13806, #e92909, #e2130b); 212 | } 213 | .gradiant4 { 214 | background-image: linear-gradient(to bottom, #e2130b, #e1110c, #e00f0d, #df0c0d, #de090e); 215 | } 216 | .gradiant5 { 217 | background-image: linear-gradient(to bottom, #de090e, #df002a, #dc003e, #d60051, #ce0061); 218 | } 219 | .gradiant6 { 220 | background-image: linear-gradient(to bottom, #ce0061, #d50079, #d60094, #d000b3, #c115d4); 221 | } 222 | .gradiant7 { 223 | background-image: linear-gradient(to bottom, #c115d4, #af0cd6, #9b07d8, #8508da, #6a0ddc); 224 | } 225 | .gradiant8 { 226 | background-image: linear-gradient(to bottom, #6a0ddc, #5f0ddd, #530edd, #450fde, #3210de); 227 | } 228 | .gradiant9 { 229 | background-image: linear-gradient(to bottom, #3210de, #2a1cdc, #2124d9, #172ad7, #0c2fd4); 230 | } 231 | .gradiant10 { 232 | background-image: linear-gradient(to bottom, #0c2fd4, #0045d8, #0056d9, #0065d8, #1b73d5); 233 | } 234 | 235 | .bouncing-balls { 236 | width: 100%; 237 | height: 100%; 238 | background: hsl(40, 50%, 95%); 239 | display: flex; 240 | align-items: center; 241 | justify-content: center; 242 | flex-direction: column; 243 | 244 | svg { 245 | width: 100vw; 246 | height: 80vh; 247 | } 248 | circle { 249 | transform-origin: 50% 50%; 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /site/style/global.scss: -------------------------------------------------------------------------------- 1 | @import 'reset.css'; 2 | @import 'normalize'; 3 | @import 'buttons'; 4 | 5 | html, 6 | body, 7 | div.app { 8 | display: block; 9 | color: #333; 10 | background: #fff; 11 | width: 100%; 12 | height: 100%; 13 | margin: 0; 14 | font-family: 'PT Sans', sans-serif; 15 | } 16 | 17 | .header { 18 | height: 55px; 19 | width: 100%; 20 | position: fixed; 21 | background-color: #333; 22 | padding-left: 20px; 23 | padding-right: 20px; 24 | 25 | .header-container { 26 | height: 100%; 27 | display: flex; 28 | align-items: center; 29 | max-width: 960px; 30 | margin: 0 auto; 31 | } 32 | 33 | .logo { 34 | font-size: 28px; 35 | color: #fff; 36 | margin-right: auto; 37 | } 38 | .github-link { 39 | color: #fff; 40 | &:hover { 41 | text-decoration: underline; 42 | } 43 | } 44 | } 45 | 46 | .links { 47 | width: 100%; 48 | height: 100%; 49 | display: flex; 50 | justify-content: center; 51 | align-items: center; 52 | flex-direction: column; 53 | 54 | .links-list { 55 | list-style: disc; 56 | } 57 | .links-heading { 58 | margin-bottom: 15px; 59 | } 60 | 61 | a { 62 | font-size: 18px; 63 | margin-top: 6px; 64 | margin-bottom: 6px; 65 | display: block; 66 | } 67 | } 68 | 69 | section.container { 70 | display: flex; 71 | max-width: 960px; 72 | margin: 0 auto; 73 | padding-top: 80px; 74 | padding-bottom: 80px; 75 | } 76 | 77 | .sidebar { 78 | min-width: 220px; 79 | padding-top: 20px; 80 | } 81 | 82 | nav { 83 | position: fixed; 84 | } 85 | nav ul { 86 | margin-top: 8px; 87 | } 88 | nav ul + h4 { 89 | margin-top: 22px; 90 | } 91 | nav li { 92 | padding: 5px 0; 93 | } 94 | 95 | .main-content { 96 | padding-left: 30px; 97 | padding-right: 30px; 98 | 99 | .spacer-top { 100 | margin-top: 20px; 101 | } 102 | 103 | p, 104 | li, 105 | h1, 106 | h2, 107 | h3, 108 | h4, 109 | h5, 110 | h6 { 111 | line-height: 150%; 112 | } 113 | } 114 | 115 | .heading { 116 | margin-top: 20px; 117 | margin-bottom: 20px; 118 | } 119 | 120 | ul.list { 121 | margin-top: 10px; 122 | margin-bottom: 10px; 123 | list-style: disc; 124 | padding-left: 20px; 125 | } 126 | 127 | .animate-box { 128 | width: 100%; 129 | min-height: 160px; 130 | background-color: #fafafa; 131 | border-bottom: 2px solid #333; 132 | display: flex; 133 | justify-content: center; 134 | align-items: center; 135 | 136 | .box, 137 | .box-shadow { 138 | width: 40px; 139 | height: 40px; 140 | border-radius: 50%; 141 | } 142 | .box { 143 | z-index: 105; 144 | position: relative; 145 | background-color: #158df7; 146 | } 147 | .box-shadow { 148 | z-index: 100; 149 | position: absolute; 150 | background-color: #07345d; 151 | } 152 | } 153 | 154 | div.code-highlight > pre { 155 | margin: 0 !important; 156 | padding: 20px !important; 157 | border-radius: 0 !important; 158 | } -------------------------------------------------------------------------------- /site/style/reset.css: -------------------------------------------------------------------------------- 1 | /* http://meyerweb.com/eric/tools/css/reset/ 2 | v2.0 | 20110126 3 | License: none (public domain) 4 | */ 5 | 6 | html, 7 | body, 8 | div, 9 | span, 10 | applet, 11 | object, 12 | iframe, 13 | h1, 14 | h2, 15 | h3, 16 | h4, 17 | h5, 18 | h6, 19 | p, 20 | blockquote, 21 | pre, 22 | a, 23 | abbr, 24 | acronym, 25 | address, 26 | big, 27 | cite, 28 | code, 29 | del, 30 | dfn, 31 | em, 32 | img, 33 | ins, 34 | kbd, 35 | q, 36 | s, 37 | samp, 38 | small, 39 | strike, 40 | strong, 41 | sub, 42 | sup, 43 | tt, 44 | var, 45 | b, 46 | u, 47 | i, 48 | center, 49 | dl, 50 | dt, 51 | dd, 52 | ol, 53 | ul, 54 | li, 55 | fieldset, 56 | form, 57 | label, 58 | legend, 59 | table, 60 | caption, 61 | tbody, 62 | tfoot, 63 | thead, 64 | tr, 65 | th, 66 | td, 67 | article, 68 | aside, 69 | canvas, 70 | details, 71 | embed, 72 | figure, 73 | figcaption, 74 | footer, 75 | header, 76 | hgroup, 77 | menu, 78 | nav, 79 | output, 80 | ruby, 81 | section, 82 | summary, 83 | time, 84 | mark, 85 | audio, 86 | video { 87 | margin: 0; 88 | padding: 0; 89 | border: 0; 90 | font-size: 100%; 91 | font: inherit; 92 | vertical-align: baseline; 93 | } 94 | /* HTML5 display-role reset for older browsers */ 95 | article, 96 | aside, 97 | details, 98 | figcaption, 99 | figure, 100 | footer, 101 | header, 102 | hgroup, 103 | menu, 104 | nav, 105 | section { 106 | display: block; 107 | } 108 | body { 109 | line-height: 1; 110 | } 111 | ol, 112 | ul { 113 | list-style: none; 114 | } 115 | blockquote, 116 | q { 117 | quotes: none; 118 | } 119 | blockquote:before, 120 | blockquote:after, 121 | q:before, 122 | q:after { 123 | content: ''; 124 | content: none; 125 | } 126 | table { 127 | border-collapse: collapse; 128 | border-spacing: 0; 129 | } -------------------------------------------------------------------------------- /src/Observer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import PropTypes from 'prop-types'; 4 | import Engine from './engine'; 5 | import { is } from './helpers'; 6 | import Parser from './parser'; 7 | 8 | class Observer extends React.Component { 9 | constructor(props) { 10 | super(props); 11 | this.state = { 12 | style: props.style, 13 | render: props.render 14 | }; 15 | 16 | this.unmounted = false; 17 | this.onComplete = this.onComplete.bind(this); 18 | this.updateAnimationProgress = this.updateAnimationProgress.bind(this); 19 | } 20 | 21 | componentDidMount() { 22 | document.addEventListener('visibilitychange', this.handleVisibilityChange); 23 | 24 | if (this.state.render) { 25 | this.animateMounting(); 26 | } 27 | } 28 | 29 | componentDidUpdate(prevProps) { 30 | const { render } = this.props; 31 | 32 | if (!prevProps.render && render) { 33 | // queue mount animation after state update 34 | this.setState({ render: true }, this.animateMounting); 35 | } 36 | 37 | if (prevProps.render && !render) { 38 | this.animateUnmounting(); 39 | } 40 | } 41 | 42 | componentWillUnmount() { 43 | this.unmounted = true; 44 | this.engine.stop(); 45 | document.removeEventListener('visibilitychange', this.handleVisibilityChange); 46 | } 47 | 48 | handleVisibilityChange() { 49 | if (!this.engine) return; 50 | this.engine.handleVisibility(document.visibilityState); 51 | } 52 | 53 | parseMount() { 54 | const el = ReactDOM.findDOMNode(this); 55 | return Parser.parse(el, { ...this.props, animate: this.props.mount }); 56 | } 57 | 58 | parseUnmount() { 59 | const options = { 60 | ...this.props, 61 | from: {}, 62 | animate: this.props.unmount 63 | }; 64 | 65 | const el = ReactDOM.findDOMNode(this); 66 | return Parser.parse(el, options); 67 | } 68 | 69 | animateMounting() { 70 | if (!this.props.mount) return; 71 | this.mount = this.parseMount(); 72 | this.mount.events.onMountStart(); 73 | this.animate(this.mount); 74 | } 75 | 76 | animateUnmounting() { 77 | if (!this.props.unmount) return; 78 | this.unmount = this.parseUnmount(); 79 | this.unmount.events.onUnmountStart(); 80 | this.animate(this.unmount); 81 | } 82 | 83 | animate(instance) { 84 | instance.progress = 0; 85 | 86 | this.engine = new Engine({ 87 | instance, 88 | animate: this.updateAnimationProgress, 89 | onComplete: this.onComplete 90 | }); 91 | 92 | this.engine.play(); 93 | } 94 | 95 | onComplete(instance) { 96 | // unmounting animation has completed 97 | if (instance === this.unmount) { 98 | instance.events.onUnmountEnd(); 99 | if (!this.unmounted) { 100 | this.setState({ render: false }); 101 | } 102 | } else { 103 | // mounting animation has completed 104 | instance.events.onMountEnd(); 105 | } 106 | } 107 | 108 | updateAnimationProgress(instance, animatedProps) { 109 | this.setState(prevState => ({ 110 | style: { 111 | ...prevState.style, 112 | ...animatedProps 113 | } 114 | })); 115 | } 116 | 117 | renderTag() { 118 | const { type: Type, children, ...propsToPass } = this.props; 119 | 120 | delete propsToPass.mount; 121 | delete propsToPass.unmount; 122 | delete propsToPass.render; 123 | delete propsToPass.children; 124 | delete propsToPass.events; 125 | 126 | return ( 127 | 128 | {children} 129 | 130 | ); 131 | } 132 | 133 | render() { 134 | const { type } = this.props; 135 | const { style, render } = this.state; 136 | 137 | if (!render) return null; 138 | 139 | let clonedElement; 140 | if (is.null(type)) { 141 | const childEl = React.Children.only(this.props.children); 142 | return React.cloneElement(childEl, { style }); 143 | } 144 | 145 | return type ? this.renderTag() : clonedElement; 146 | } 147 | } 148 | 149 | Observer.defaultProps = { 150 | render: true 151 | }; 152 | 153 | Observer.propTypes = { 154 | type: PropTypes.string, 155 | render: PropTypes.bool, 156 | mount: PropTypes.oneOfType([PropTypes.array, PropTypes.object]), 157 | unmount: PropTypes.oneOfType([PropTypes.array, PropTypes.object]), 158 | children: PropTypes.node 159 | }; 160 | 161 | ['div', 'span', 'a', 'button', 'li', 'img'].forEach(type => { 162 | const func = props => ; 163 | Observer[type] = func; 164 | Observer[type].displayName = `Observer.${type}`; 165 | Observer[type].tweenful = true; 166 | }); 167 | 168 | export default Observer; 169 | -------------------------------------------------------------------------------- /src/ObserverGroup.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Observer from './Observer'; 4 | import { toArray, find } from './helpers'; 5 | 6 | class ObserverGroup extends React.Component { 7 | constructor(props) { 8 | super(props); 9 | this.state = { 10 | children: toArray(props.children).map(child => this.mapChild(child)) 11 | }; 12 | 13 | this.didRender = false; 14 | } 15 | 16 | mapChild(child) { 17 | const { config, skipInitial } = this.props; 18 | 19 | const childEvents = child.props.events || {}; 20 | const skipMountAnimation = !this.didRender && skipInitial; 21 | 22 | const events = { 23 | ...childEvents, 24 | onUnmountEnd: () => { 25 | (childEvents.onUnmountEnd || function() {})(); 26 | this.onChildUnmount(child.key); 27 | } 28 | }; 29 | 30 | if (!child.type.tweenful) { 31 | const mount = { ...config.mount, duration: 10 }; 32 | const passedProps = { 33 | ...config, 34 | mount: skipMountAnimation ? mount : config.mount, 35 | key: child.key, 36 | events 37 | }; 38 | 39 | return React.createElement(Observer, passedProps, child); 40 | } 41 | 42 | if (skipMountAnimation) { 43 | const propsToPass = { events, mount: { ...child.props.mount, duration: 10 } }; 44 | return React.cloneElement(child, propsToPass); 45 | } 46 | 47 | return React.cloneElement(child, { events }); 48 | } 49 | 50 | filterChildren(prevData, currentData) { 51 | const prev = toArray(prevData); 52 | const current = toArray(currentData); 53 | 54 | const added = current.filter(child => !prev.find(({ key }) => key === child.key)); 55 | const removed = prev.filter(child => !current.find(({ key }) => key === child.key)); 56 | 57 | const left = prev.filter(e => !find(removed, e)); 58 | const right = current.filter(e => !find(removed, e) && !find(added, e)); 59 | 60 | if (left.find((_e, i) => right[i].key !== left[i].key)) { 61 | throw new Error('Order has changed'); 62 | } 63 | 64 | const data = []; 65 | let prevIndex = 0; 66 | let currentIndex = 0; 67 | 68 | while (true) { 69 | if (prevIndex === prev.length) { 70 | data.push(...current.filter((_el, i) => i >= currentIndex).map(r => this.mapChild(r))); 71 | return data; 72 | } 73 | 74 | if (currentIndex === current.length) { 75 | data.push(...prev.filter((_el, i) => i >= prevIndex).map(r => this.mapRemovedChild(r))); 76 | return data; 77 | } 78 | 79 | const prevEl = prev[prevIndex]; 80 | const currEl = current[currentIndex]; 81 | if (prevEl.key === currEl.key) { 82 | data.push(prevEl); 83 | prevIndex++; 84 | currentIndex++; 85 | } else if (find(added, currEl)) { 86 | data.push(this.mapChild(currEl)); 87 | currentIndex++; 88 | } else { 89 | data.push(this.mapRemovedChild(prevEl)); 90 | prevIndex++; 91 | } 92 | } 93 | } 94 | 95 | mapRemovedChild(child) { 96 | const stateMappedChild = this.state.children.find(c => c.key === child.key); 97 | return React.cloneElement(stateMappedChild, { render: false }); 98 | } 99 | 100 | componentDidUpdate(prevProps) { 101 | this.didRender = true; 102 | 103 | if (this.props.children !== prevProps.children) { 104 | this.setState(prevState => ({ 105 | children: this.filterChildren(prevState.children, this.props.children) 106 | })); 107 | } 108 | } 109 | 110 | onChildUnmount(key) { 111 | this.setState(prevState => ({ 112 | children: prevState.children.filter(child => child.key !== key) 113 | })); 114 | } 115 | 116 | render() { 117 | const { type: Type } = this.props; 118 | const { children } = this.state; 119 | 120 | if (Type) { 121 | const { ...propsToPass } = this.props; 122 | delete propsToPass.children; 123 | delete propsToPass.config; 124 | return {children}; 125 | } 126 | 127 | return children; 128 | } 129 | } 130 | 131 | ObserverGroup.propTypes = { 132 | skipInitial: PropTypes.bool, 133 | children: PropTypes.oneOfType([ 134 | PropTypes.node, 135 | PropTypes.instanceOf(Observer), 136 | PropTypes.arrayOf(PropTypes.instanceOf(Observer)), 137 | PropTypes.arrayOf(PropTypes.node) 138 | ]) 139 | }; 140 | 141 | ObserverGroup.div = props => ; 142 | ObserverGroup.span = props => ; 143 | ObserverGroup.ul = props => ; 144 | 145 | ObserverGroup.div.displayName = 'ObserverGroup.div'; 146 | ObserverGroup.span.displayName = 'ObserverGroup.span'; 147 | ObserverGroup.ul.displayName = 'ObserverGroup.ul'; 148 | 149 | export default ObserverGroup; 150 | -------------------------------------------------------------------------------- /src/SVG.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { parseStartingTransform } from './helpers'; 4 | import { getSvgElLength } from './helpers'; 5 | import Parser from './parser'; 6 | import Engine from './engine'; 7 | 8 | class SVG extends React.Component { 9 | constructor(props) { 10 | super(props); 11 | 12 | const transformFrom = parseStartingTransform(props); 13 | this.transformFrom = transformFrom; 14 | this.state = { 15 | style: { 16 | ...props.style 17 | }, 18 | render: props.render 19 | }; 20 | 21 | this.el = React.createRef(); 22 | this.updateAnimationProgress = this.updateAnimationProgress.bind(this); 23 | this.onComplete = this.onComplete.bind(this); 24 | this.handleVisibilityChange = this.handleVisibilityChange.bind(this); 25 | } 26 | 27 | handleVisibilityChange() { 28 | if (!this.engine) return; 29 | this.engine.handleVisibility(document.visibilityState); 30 | } 31 | 32 | componentDidMount() { 33 | document.addEventListener('visibilitychange', this.handleVisibilityChange); 34 | if (!this.state.render) return; 35 | 36 | this.setState( 37 | prevState => ({ 38 | style: { 39 | ...prevState.style, 40 | strokeDasharray: getSvgElLength(this.el.current), 41 | strokeDashoffset: 1500 42 | } 43 | }), 44 | () => this.play(true) 45 | ); 46 | } 47 | 48 | play(reset = false) { 49 | if (this.engine) { 50 | this.engine.stop(); 51 | } 52 | 53 | if (reset) { 54 | this.instance = Parser.parse(this.el.current, this.props, this.transformFrom); 55 | } 56 | 57 | this.engine = new Engine({ 58 | id: this.props.id, 59 | instance: this.instance, 60 | animate: this.updateAnimationProgress, 61 | el: this.el.current, 62 | onComplete: this.onComplete 63 | }); 64 | 65 | this.engine.play(); 66 | } 67 | 68 | stop() { 69 | this.engine.stop(); 70 | } 71 | 72 | componentDidUpdate(prevProps) { 73 | const { render } = this.props; 74 | 75 | if (prevProps.render && !render) { 76 | this.setState({ render }, () => this.stop()); 77 | } 78 | 79 | if (!prevProps.render && render) { 80 | this.setState({ render }, () => this.play(true)); 81 | } 82 | } 83 | 84 | componentWillUnmount() { 85 | this.stop(); 86 | document.removeEventListener('visibilitychange', this.handleVisibilityChange); 87 | } 88 | 89 | resetSvgPath() { 90 | this.setState(prevState => ({ 91 | style: { 92 | ...prevState.style, 93 | strokeDasharray: 0, 94 | strokeDashoffset: null 95 | } 96 | })); 97 | } 98 | 99 | onComplete(instance) { 100 | const { loop } = instance; 101 | if (loop === false) { 102 | return this.resetSvgPath(); 103 | } else if (loop === true) { 104 | return this.play(); 105 | } else if (instance.timesCompleted < loop) { 106 | return this.play(); 107 | } 108 | } 109 | 110 | updateAnimationProgress(instance, animatedProps) { 111 | this.setState(prevState => ({ 112 | style: { 113 | ...prevState.style, 114 | ...animatedProps 115 | } 116 | })); 117 | } 118 | 119 | render() { 120 | const { style, render } = this.state; 121 | const { type: Type, children, ...propsToPass } = this.props; 122 | 123 | if (!render) return null; 124 | 125 | delete propsToPass.delay; 126 | delete propsToPass.endDelay; 127 | delete propsToPass.animate; 128 | delete propsToPass.keyframes; 129 | delete propsToPass.duration; 130 | delete propsToPass.easing; 131 | delete propsToPass.from; 132 | delete propsToPass.type; 133 | delete propsToPass.animate; 134 | delete propsToPass.render; 135 | delete propsToPass.children; 136 | delete propsToPass.transform; 137 | 138 | return ( 139 | 140 | {children} 141 | 142 | ); 143 | } 144 | } 145 | 146 | SVG.defaultProps = { 147 | render: true 148 | }; 149 | 150 | SVG.propTypes = { 151 | render: PropTypes.bool, 152 | type: PropTypes.string, 153 | children: PropTypes.node, 154 | animate: PropTypes.oneOfType([PropTypes.array, PropTypes.object, PropTypes.func]) 155 | }; 156 | 157 | ['circle', 'ellipse', 'path', 'line', 'polygon', 'polyline', 'rect', 'text', 'g'].forEach(type => { 158 | const func = props => ; 159 | SVG[type] = func; 160 | SVG[type].displayName = `SVG.${type}`; 161 | SVG[type].tweenful = true; 162 | }); 163 | 164 | export default SVG; 165 | -------------------------------------------------------------------------------- /src/Tweenful.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { parseStartingTransform } from './helpers'; 4 | import Parser from './parser'; 5 | import Engine from './engine'; 6 | 7 | class Tweenful extends React.Component { 8 | constructor(props) { 9 | super(props); 10 | 11 | const transformFrom = parseStartingTransform(props); 12 | const transform = transformFrom.order.map(prop => ({ 13 | prop, 14 | args: transformFrom.domProperties[prop] 15 | })); 16 | const mappedArgs = args => `${args.map(arg => `${arg.value}${arg.unit}`).join(', ')}`; 17 | const transformFunctions = transform.map(t => `${t.prop}(${mappedArgs(t.args)})`); 18 | 19 | this.transformFrom = transformFrom; 20 | this.element = React.createRef(); 21 | this.state = { 22 | style: { 23 | ...props.style, 24 | transform: transformFunctions.join(' ') 25 | }, 26 | render: props.render 27 | }; 28 | 29 | this.handleVisibilityChange = this.handleVisibilityChange.bind(this); 30 | this.updateAnimationProgress = this.updateAnimationProgress.bind(this); 31 | this.onComplete = this.onComplete.bind(this); 32 | } 33 | 34 | handleVisibilityChange() { 35 | if (!this.engine) return; 36 | this.engine.handleVisibility(document.visibilityState); 37 | } 38 | 39 | componentDidMount() { 40 | document.addEventListener('visibilitychange', this.handleVisibilityChange); 41 | 42 | if (this.state.render) { 43 | this.play(true); 44 | } 45 | } 46 | 47 | componentDidUpdate(prevProps) { 48 | const { render, paused, animate } = this.props; 49 | 50 | if (prevProps.animate !== animate) { 51 | return this.play(true); 52 | } 53 | 54 | if (prevProps.render ^ render) { 55 | this.setState({ render }, () => (render ? this.play(true) : this.stop())); 56 | } 57 | 58 | if (prevProps.paused ^ paused) { 59 | (paused && this.pause()) || this.resume(); 60 | } 61 | } 62 | 63 | componentWillUnmount() { 64 | this.stop(); 65 | document.removeEventListener('visibilitychange', this.handleVisibilityChange); 66 | } 67 | 68 | onComplete(instance) { 69 | const { loop, timesCompleted } = instance; 70 | 71 | if (!loop) return; 72 | if (loop === true) { 73 | return this.play(); 74 | } 75 | 76 | if (timesCompleted < loop) { 77 | return this.play(); 78 | } 79 | } 80 | 81 | play(reset = false) { 82 | if (this.engine) { 83 | this.engine.stop(); 84 | } 85 | 86 | if (reset) { 87 | this.instance = Parser.parse(this.element.current, this.props, this.transformFrom); 88 | } 89 | 90 | this.engine = new Engine({ 91 | instance: this.instance, 92 | animate: this.updateAnimationProgress, 93 | onComplete: this.onComplete 94 | }); 95 | 96 | this.engine.play(); 97 | } 98 | 99 | pause() { 100 | this.engine.pause(); 101 | } 102 | 103 | resume() { 104 | this.engine.resume(); 105 | } 106 | 107 | stop() { 108 | this.engine.stop(); 109 | } 110 | 111 | updateAnimationProgress(instance, animatedProps) { 112 | this.setState(prevState => { 113 | return { 114 | style: { 115 | ...prevState.style, 116 | ...animatedProps 117 | } 118 | }; 119 | }); 120 | } 121 | 122 | render() { 123 | const { 124 | props: { type: Type, ...passedProps }, 125 | state: { render, style } 126 | } = this; 127 | 128 | if (!render) return null; 129 | 130 | delete passedProps.delay; 131 | delete passedProps.endDelay; 132 | delete passedProps.animate; 133 | delete passedProps.keyframes; 134 | delete passedProps.duration; 135 | delete passedProps.easing; 136 | delete passedProps.from; 137 | delete passedProps.loop; 138 | delete passedProps.direction; 139 | delete passedProps.render; 140 | delete passedProps.running; 141 | delete passedProps.paused; 142 | delete passedProps.context; 143 | delete passedProps.events; 144 | delete passedProps.transform; 145 | 146 | return ; 147 | } 148 | } 149 | 150 | Tweenful.defaultProps = { 151 | render: true, 152 | running: true, 153 | paused: false 154 | }; 155 | 156 | Tweenful.propTypes = { 157 | type: PropTypes.string, 158 | render: PropTypes.bool, 159 | running: PropTypes.bool, 160 | paused: PropTypes.bool, 161 | children: PropTypes.node 162 | }; 163 | 164 | ['div', 'span', 'a', 'button', 'li', 'img'].forEach(type => { 165 | const func = props => ; 166 | Tweenful[type] = func; 167 | Tweenful[type].displayName = `Tweenful.${type}`; 168 | Tweenful[type].tweenful = true; 169 | }); 170 | 171 | export default Tweenful; 172 | -------------------------------------------------------------------------------- /src/easings/index.js: -------------------------------------------------------------------------------- 1 | const pow = Math.pow; 2 | const c1 = 1.70158; 3 | const c2 = c1 * 1.525; 4 | 5 | export const elastic = (amplitude, period) => t => { 6 | const a = minMax(amplitude, 1, 10); 7 | const p = minMax(period, 0.1, 2); 8 | 9 | var s = Math.asin((1 / a) * p); 10 | return ( 11 | ((t = t * 2 - 1) < 0 12 | ? a * Math.pow(2, 10 * t) * Math.sin((s - t) / p) 13 | : 2 - a * Math.pow(2, -10 * t) * Math.sin((s + t) / p)) / 2 14 | ); 15 | }; 16 | 17 | const minMax = (val, min, max) => { 18 | return Math.min(Math.max(val, min), max); 19 | }; 20 | 21 | const bounceOut = x => { 22 | const n1 = 7.5625; 23 | const d1 = 2.75; 24 | 25 | if (x < 1 / d1) { 26 | return n1 * x * x; 27 | } else if (x < 2 / d1) { 28 | return n1 * (x -= 1.5 / d1) * x + 0.75; 29 | } else if (x < 2.5 / d1) { 30 | return n1 * (x -= 2.25 / d1) * x + 0.9375; 31 | } else { 32 | return n1 * (x -= 2.625 / d1) * x + 0.984375; 33 | } 34 | }; 35 | 36 | export default { 37 | // no easing, no acceleration 38 | linear: t => { 39 | return t; 40 | }, 41 | // accelerating from zero velocity 42 | easeInQuad: t => { 43 | return t * t; 44 | }, 45 | // decelerating to zero velocity 46 | easeOutQuad: t => { 47 | return t * (2 - t); 48 | }, 49 | // acceleration until halfway, then deceleration 50 | easeInOutQuad: t => { 51 | return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t; 52 | }, 53 | // accelerating from zero velocity 54 | easeInCubic: t => { 55 | return t * t * t; 56 | }, 57 | // decelerating to zero velocity 58 | easeOutCubic: t => { 59 | return --t * t * t + 1; 60 | }, 61 | // acceleration until halfway, then deceleration 62 | easeInOutCubic: t => { 63 | return t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1; 64 | }, 65 | // accelerating from zero velocity 66 | easeInQuart: t => { 67 | return t * t * t * t; 68 | }, 69 | // decelerating to zero velocity 70 | easeOutQuart: t => { 71 | return 1 - --t * t * t * t; 72 | }, 73 | // acceleration until halfway, then deceleration 74 | easeInOutQuart: t => { 75 | return t < 0.5 ? 8 * t * t * t * t : 1 - 8 * --t * t * t * t; 76 | }, 77 | // accelerating from zero velocity 78 | easeInQuint: t => { 79 | return t * t * t * t * t; 80 | }, 81 | // decelerating to zero velocity 82 | easeOutQuint: t => { 83 | return 1 + --t * t * t * t * t; 84 | }, 85 | // acceleration until halfway, then deceleration 86 | easeInOutQuint: t => { 87 | return t < 0.5 ? 16 * t * t * t * t * t : 1 + 16 * --t * t * t * t * t; 88 | }, 89 | easeInOutBack: x => { 90 | return x < 0.5 91 | ? (pow(2 * x, 2) * ((c2 + 1) * 2 * x - c2)) / 2 92 | : (pow(2 * x - 2, 2) * ((c2 + 1) * (x * 2 - 2) + c2) + 2) / 2; 93 | }, 94 | easeInBounce: x => { 95 | return 1 - bounceOut(1 - x); 96 | }, 97 | sine: t => 1 - Math.cos((t * Math.PI) / 2), 98 | circ: t => 1 - Math.sqrt(1 - t * t), 99 | back: t => t * t * (3 * t - 2), 100 | bounce: t => { 101 | let pow2; 102 | let b = 4; 103 | while (t < ((pow2 = Math.pow(2, --b)) - 1) / 11); 104 | return 1 / Math.pow(4, 3 - b) - 7.5625 * Math.pow((pow2 * 3 - 2) / 22 - t, 2); 105 | }, 106 | easeOutBounce: bounceOut 107 | }; 108 | -------------------------------------------------------------------------------- /src/engine/index.js: -------------------------------------------------------------------------------- 1 | import { getAnimationProgress, calculateProgress } from '../helpers'; 2 | import { computeProgressTick, updateTimesCompleted } from './utils'; 3 | 4 | class Engine { 5 | constructor(options) { 6 | this.frame = null; 7 | this.options = options; 8 | this.didComplete = false; 9 | this.lastTime = 0; 10 | this.startTime = 0; 11 | this.lastTick = 0; 12 | 13 | this.progress = this.progress.bind(this); 14 | } 15 | 16 | play() { 17 | this.frame = requestAnimationFrame(this.progress); 18 | } 19 | 20 | progress(now) { 21 | const { 22 | el, 23 | instance, 24 | animate, 25 | onComplete, 26 | instance: { duration, animations } 27 | } = this.options; 28 | 29 | if (!this.startTime) { 30 | this.didComplete = false; 31 | this.startTime = now; 32 | } 33 | 34 | const progressTick = computeProgressTick(instance); 35 | const tick = now + progressTick + (this.lastTime - this.startTime); 36 | 37 | instance.time = tick; 38 | instance.progress = calculateProgress(tick, duration); 39 | const animatedProps = getAnimationProgress(tick, this.lastTick, animations, el); 40 | 41 | animate(instance, animatedProps); 42 | 43 | if (instance.progress === 1) { 44 | instance.events.onAnimationEnd(); 45 | updateTimesCompleted(instance); 46 | onComplete(instance); 47 | } 48 | 49 | this.lastTick = tick; 50 | if (tick < duration) { 51 | this.play(); 52 | } else { 53 | this.didComplete = true; 54 | this.reset(); 55 | } 56 | } 57 | 58 | handleVisibility(state) { 59 | state === 'visible' ? this.resume() : this.pause(); 60 | } 61 | 62 | pause() { 63 | if (!this.didComplete) { 64 | cancelAnimationFrame(this.frame); 65 | } 66 | } 67 | 68 | resume() { 69 | if (!this.didComplete) { 70 | this.startTime = 0; 71 | this.lastTime = this.options.instance.time; 72 | this.play(); 73 | } 74 | } 75 | 76 | stop() { 77 | cancelAnimationFrame(this.frame); 78 | this.reset(); 79 | } 80 | 81 | reset() { 82 | this.frame = null; 83 | this.lastTime = 0; 84 | this.startTime = 0; 85 | this.lastTick = 0; 86 | this.tick = 0; 87 | } 88 | } 89 | 90 | export default Engine; 91 | -------------------------------------------------------------------------------- /src/engine/utils.js: -------------------------------------------------------------------------------- 1 | export const modulo = (delay, duration) => { 2 | while (delay >= duration) { 3 | delay -= duration; 4 | } 5 | 6 | return delay; 7 | }; 8 | 9 | export const computeProgressTick = instance => { 10 | // ignore negative delay after the first animation occurred 11 | if (instance.delay >= 0 || instance.timesCompleted > 0) return 0; 12 | 13 | const { duration, loop } = instance; 14 | const delay = Math.abs(instance.delay); 15 | 16 | if (loop === true) { 17 | return modulo(delay, duration); 18 | } else if (loop === false) { 19 | return delay > duration ? duration : modulo(delay, duration); 20 | } else if (loop > 0) { 21 | // delay might last longer than the animation itself 22 | // else we'll just calculate the modulo 23 | const totalDuration = loop * duration; 24 | return delay > totalDuration ? duration : modulo(delay, duration); 25 | } 26 | 27 | return 0; 28 | }; 29 | 30 | export const updateTimesCompleted = instance => { 31 | const { duration, delay, loop } = instance; 32 | 33 | if (!instance.timesCompleted && delay < 0 && loop > 0) { 34 | let absDelay = Math.abs(delay); 35 | if (absDelay < duration) { 36 | instance.timesCompleted++; 37 | } 38 | 39 | while (absDelay >= duration) { 40 | absDelay -= duration; 41 | instance.timesCompleted++; 42 | } 43 | } else { 44 | instance.timesCompleted++; 45 | } 46 | }; 47 | -------------------------------------------------------------------------------- /src/helpers/constants.js: -------------------------------------------------------------------------------- 1 | export const validDOMProperties = [ 2 | 'left', 3 | 'right', 4 | 'top', 5 | 'fill', 6 | 'bottom', 7 | 'opacity', 8 | 'strokeDashoffset', 9 | 'backgroundColor', 10 | 'borderRadius', 11 | 'height', 12 | 'width', 13 | 'marginBottom', 14 | 'marginTop' 15 | ]; 16 | 17 | export const colorProps = ['fill', 'color', 'backgroundColor']; 18 | 19 | export const transformProps = [ 20 | 'translate', 21 | 'translate3d', 22 | 'translateX', 23 | 'translateY', 24 | 'translateZ', 25 | 'skew', 26 | 'skewX', 27 | 'skewY', 28 | 'scale', 29 | 'scale3d', 30 | 'scaleX', 31 | 'scaleY', 32 | 'scaleZ', 33 | 'rotate', 34 | 'rotate3d', 35 | 'rotateX', 36 | 'rotateY', 37 | 'rotateZ' 38 | ]; 39 | 40 | export const validKeyframeProps = [ 41 | ...validDOMProperties.slice(0, validDOMProperties.length - 1), 42 | ...transformProps 43 | ]; 44 | 45 | export const svgProps = [ 46 | 'stroke', 47 | 'strokeDasharray', 48 | 'strokeDashoffset', 49 | 'strokeDashOpacity', 50 | 'fill', 51 | 'fillOpacity' 52 | ]; 53 | 54 | export const validTransforms = { 55 | translate: { argsMin: 1, argsMax: 2, start: 0 }, 56 | translate3d: { argsMin: 3, argsMax: 3, start: 0 }, 57 | translateX: { argsMin: 1, argsMax: 1, start: 0 }, 58 | translateY: { argsMin: 1, argsMax: 1, start: 0 }, 59 | translateZ: { argsMin: 1, argsMax: 1, start: 0 }, 60 | skew: { argsMin: 1, argsMax: 2, start: 0 }, 61 | skewX: { argsMin: 1, argsMax: 1, start: 0 }, 62 | skewY: { argsMin: 1, argsMax: 1, start: 0 }, 63 | scale: { argsMin: 1, argsMax: 2, start: 1 }, 64 | scale3d: { argsMin: 3, argsMax: 3, start: 1 }, 65 | scaleX: { argsMin: 1, argsMax: 1, start: 1 }, 66 | scaleY: { argsMin: 1, argsMax: 1, start: 1 }, 67 | scaleZ: { argsMin: 1, argsMax: 1, start: 1 }, 68 | rotate: { argsMin: 1, argsMax: 1, start: 0 }, 69 | rotate3d: { argsMin: 4, argsMax: 4, start: 0 }, 70 | rotateX: { argsMin: 1, argsMax: 1, start: 0 }, 71 | rotateY: { argsMin: 1, argsMax: 1, start: 0 }, 72 | rotateZ: { argsMin: 1, argsMax: 1, start: 0 } 73 | }; 74 | 75 | export const regexExpressions = { 76 | // 100px, 25%, 30deg 77 | functionArguments: () => /[ \t]*([-]{0,1}\d+(\.*\d*))(px|%|deg|){1}[ \t]*/g, 78 | // 100px 79 | unit: () => /(([-]?[0-9]+.[0-9]+)|([-]?[0-9]+))(%|[a-zA-Z]+|)/g, 80 | // 100% 25rem 30px 81 | valueUnitPair: () => 82 | /[+-]?\d*\.?\d+(?:\.\d+)?(%|px|pt|em|rem|in|cm|mm|ex|ch|pc|vw|vh|vargsMin|vargsMax|deg|rad)?/g 83 | }; 84 | -------------------------------------------------------------------------------- /src/helpers/index.js: -------------------------------------------------------------------------------- 1 | export * from './utils'; 2 | export * from './svg-utils'; 3 | -------------------------------------------------------------------------------- /src/helpers/svg-utils.js: -------------------------------------------------------------------------------- 1 | const getDistance = (p1, p2) => { 2 | const x = Math.pow(p2.x - p1.x, 2); 3 | const y = Math.pow(p2.y - p1.y, 2); 4 | return Math.sqrt(x + y); 5 | }; 6 | 7 | const getCircleLength = el => { 8 | return Math.PI * 2 * el.getAttribute('r'); 9 | }; 10 | 11 | const getRectLength = el => { 12 | const width = el.getAttribute('width') * 2; 13 | const height = el.getAttribute('height') * 2; 14 | return width + height; 15 | }; 16 | 17 | const getLineLength = el => { 18 | return getDistance( 19 | { x: el.getAttribute('x1'), y: el.getAttribute('y1') }, 20 | { x: el.getAttribute('x2'), y: el.getAttribute('y2') } 21 | ); 22 | }; 23 | 24 | const getPolylineLength = el => { 25 | const points = el.points; 26 | let totalLength = 0; 27 | let previousPos; 28 | for (let i = 0; i < points.numberOfItems; i++) { 29 | const currentPos = points.getItem(i); 30 | if (i > 0) { 31 | totalLength += getDistance(previousPos, currentPos); 32 | } 33 | previousPos = currentPos; 34 | } 35 | return totalLength; 36 | }; 37 | 38 | const getPolygonLength = el => { 39 | const points = el.points; 40 | const distance = getDistance(points.getItem(points.numberOfItems - 1), points.getItem(0)); 41 | return getPolylineLength(el) + distance; 42 | }; 43 | 44 | export const getSvgElLength = el => { 45 | switch (el.tagName.toLowerCase()) { 46 | case 'path': 47 | return el.getTotalLength(); 48 | case 'circle': 49 | return getCircleLength(el); 50 | case 'rect': 51 | return getRectLength(el); 52 | case 'line': 53 | return getLineLength(el); 54 | case 'polyline': 55 | return getPolylineLength(el); 56 | case 'polygon': 57 | return getPolygonLength(el); 58 | default: 59 | return 0; 60 | } 61 | }; 62 | -------------------------------------------------------------------------------- /src/helpers/utils.js: -------------------------------------------------------------------------------- 1 | import parseColor from 'color-parser'; 2 | import easings from '../easings'; 3 | import { getSvgElLength } from './svg-utils'; 4 | import { 5 | colorProps, 6 | validTransforms, 7 | validDOMProperties, 8 | validKeyframeProps, 9 | regexExpressions, 10 | transformProps 11 | } from './constants'; 12 | 13 | export const percentage = object => duration => { 14 | const keys = Object.keys(object) 15 | .map(i => parseInt(i.slice(0, -1), 10)) 16 | .sort((a, b) => { 17 | if (a < b) return -1; 18 | if (a > b) return 1; 19 | return 0; 20 | }); 21 | 22 | const calculateDiff = index => { 23 | const curr = keys[index]; 24 | const next = keys[index + 1]; 25 | return next - curr; 26 | }; 27 | 28 | const parsedAnimate = []; 29 | keys.forEach((key, index) => { 30 | if (index + 1 === keys.length) return; 31 | 32 | if (index === 0) { 33 | parsedAnimate.push({ 34 | duration: 0, 35 | ...object[`${key}%`] 36 | }); 37 | } 38 | 39 | const nextKey = keys[index + 1]; 40 | const next = object[`${nextKey}%`]; 41 | const percentRange = calculateDiff(index); 42 | parsedAnimate.push({ 43 | duration: (percentRange / 100) * duration, 44 | ...next 45 | }); 46 | }); 47 | 48 | return parsedAnimate; 49 | }; 50 | 51 | export const find = (arr, e) => arr.find(a => e.key === a.key); 52 | 53 | export const toArray = object => { 54 | if (is.null(object)) return []; 55 | if (Array.isArray(object)) return object; 56 | return [object]; 57 | }; 58 | 59 | export const is = { 60 | svg: el => el instanceof SVGElement, 61 | null: value => value === undefined || value === null, 62 | array: el => Array.isArray(el) 63 | }; 64 | 65 | export const toUnit = val => { 66 | const result = regexExpressions.valueUnitPair().exec(val); 67 | return result ? result[1] : ''; 68 | }; 69 | 70 | export const auto = () => [{ value: 'auto', unit: '', auto: true }]; 71 | 72 | export const unitToNumber = string => { 73 | if (is.null(string) || string === '') return null; 74 | if (string === 'auto') return auto(); 75 | 76 | const groups = getRegexGroups(regexExpressions.valueUnitPair(), string); 77 | return groups.map(group => { 78 | const match = regexExpressions.valueUnitPair().exec(group); 79 | return { 80 | value: parseFloat(match[0]), 81 | unit: match[1] || '' 82 | }; 83 | }); 84 | }; 85 | 86 | export const normalizeTweenUnit = (el, tween) => { 87 | const { from: tweenFrom, to: tweenTo } = tween; 88 | 89 | for (let i = 0; i < tweenFrom.length; i += 1) { 90 | const from = tweenFrom[i]; 91 | const to = tweenTo[i]; 92 | 93 | // color will return an array such as [255, 255, 255, 255] 94 | if (is.null(from.unit) || is.null(to.unit)) continue; 95 | // skip pixel to pixel conversion 96 | if (from.unit === 'px' && to.unit === 'px') continue; 97 | // skip unitless conversion for now, such as opacity 98 | if (from.unit === '' && to.unit === '') continue; 99 | 100 | if (to.unit === 'px' && from.auto) continue; 101 | if (from.unit === 'px' && to.auto) continue; 102 | 103 | if (from.unit !== 'px') { 104 | const convertedValue = convertUnitToPixels(el, from.value, from.unit); 105 | from.value = convertedValue; 106 | from.unit = 'px'; 107 | } 108 | 109 | if (to.unit !== 'px') { 110 | const convertedValue = convertUnitToPixels(el, to.value, to.unit); 111 | to.value = convertedValue; 112 | to.unit = 'px'; 113 | } 114 | } 115 | }; 116 | 117 | export const parseStartingTransform = ({ transform }) => { 118 | if (!transform) return { order: [], domProperties: {} }; 119 | 120 | const keys = Object.keys(transform); 121 | const domProperties = getValidDOMProperties(keys, transformProps); 122 | const props = {}; 123 | domProperties.forEach(prop => (props[prop] = unitToNumber(transform[prop]))); 124 | 125 | return { 126 | order: domProperties, 127 | domProperties: props 128 | }; 129 | }; 130 | 131 | export const convertUnitToPixels = (el, value, conversionUnit) => { 132 | const unit = toUnit(value); 133 | if (['deg', 'rad', 'turn'].includes(unit)) return; 134 | 135 | const baseline = 100; 136 | const tempEl = el.cloneNode(); 137 | const parentEl = el.parentNode && el.parentNode !== document ? el.parentNode : document.body; 138 | 139 | parentEl.appendChild(tempEl); 140 | tempEl.style.position = 'relative'; 141 | tempEl.style.height = `${baseline}${conversionUnit}`; 142 | const offset = tempEl.offsetHeight; 143 | parentEl.removeChild(tempEl); 144 | 145 | return (parseFloat(value) * offset) / 100; 146 | }; 147 | 148 | export const getValidDOMProperties = (properties, lookupList = validDOMProperties) => { 149 | const validProperties = []; 150 | properties.forEach(property => { 151 | if (lookupList.indexOf(property) > -1) { 152 | validProperties.push(property); 153 | } 154 | }); 155 | 156 | return validProperties; 157 | }; 158 | 159 | export const getPropertyProgress = (tween, easing) => { 160 | const eased = (from, to) => from + easing * (to - from); 161 | const { from, to } = tween; 162 | 163 | if (tween.color) { 164 | const rgb = { 165 | r: eased(from[0], to[0]), 166 | g: eased(from[1], to[1]), 167 | b: eased(from[2], to[2]), 168 | a: eased(from[3], to[3]) 169 | }; 170 | return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${rgb.a})`; 171 | } else if (tween.path) { 172 | return `${eased(from[0].value, to[0].value)}`; 173 | } 174 | 175 | const [{ unit }] = from; 176 | return `${eased(from[0].value, to[0].value)}${unit}`; 177 | }; 178 | 179 | export const getAnimationProgress = (tick, lastTick, animations, el) => { 180 | const getTween = (tweens, tick) => tweens.find(({ start, end }) => tick >= start && tick <= end); 181 | 182 | const animatedProps = {}; 183 | const transforms = []; 184 | 185 | animations.forEach(animate => { 186 | const { property, tweens } = animate; 187 | const tween = getTween(tweens, tick) || getTween(tweens, lastTick); 188 | // check if lastTick matches any tween in order to complete animation at 100% 189 | if (!tween) return; 190 | 191 | const tweenProgress = calculateProgress(tick - tween.start, tween.duration); 192 | const easing = tween.easing(tweenProgress); 193 | 194 | if (transformProps.indexOf(property) > -1) { 195 | const easedTransformValues = tween.to.map(({ value: to, unit }, index) => { 196 | const { value: from } = tween.from[index]; 197 | const eased = from + easing * (to - from); 198 | return `${eased}${unit}`; 199 | }); 200 | 201 | transforms.push(`${property}(${easedTransformValues.map(i => i)})`); 202 | } else if (property === 'strokeDashoffset') { 203 | const pathLength = getSvgElLength(el); 204 | animatedProps['strokeDasharray'] = pathLength; 205 | animatedProps['strokeDashoffset'] = (getPropertyProgress(tween, easing) / 100) * pathLength; 206 | } else { 207 | animatedProps[property] = getPropertyProgress(tween, easing); 208 | } 209 | }); 210 | 211 | if (transforms.length > 0) { 212 | animatedProps.transform = transforms.reduce((funcs, t) => `${funcs} ${t}`); 213 | } 214 | 215 | return animatedProps; 216 | }; 217 | 218 | export const getAnimatableProperties = array => { 219 | const animatableProps = []; 220 | array.forEach(elem => { 221 | const keys = Object.keys(elem); 222 | const domProperties = getValidDOMProperties(keys, validKeyframeProps); 223 | domProperties.forEach(prop => { 224 | if (animatableProps.indexOf(prop) === -1) { 225 | animatableProps.push(prop); 226 | } 227 | }); 228 | }); 229 | 230 | return animatableProps; 231 | }; 232 | 233 | const getTransformMapping = (transformFrom, mappedTransform, property) => { 234 | if (is.null(transformFrom.domProperties[property])) { 235 | // will help knowing where to start animating from (e.g. 0, 1) 236 | return new Array(mappedTransform.argsMax).fill(0).map(() => ({ 237 | value: mappedTransform.start, 238 | unit: '' 239 | })); 240 | } 241 | 242 | return transformFrom.domProperties[property]; 243 | }; 244 | 245 | export const getStartingValues = (element, transformFrom, animatableProps) => { 246 | const defaultProperties = {}; 247 | const computed = getComputedStyle(element); 248 | 249 | animatableProps.forEach(property => { 250 | const mappedTransform = validTransforms[property]; 251 | if (mappedTransform) { 252 | defaultProperties[property] = getTransformMapping(transformFrom, mappedTransform, property); 253 | return; 254 | } 255 | 256 | let val = computed[property]; 257 | if (property === 'height') { 258 | val = element.scrollHeight; 259 | } else if (property === 'width') { 260 | val = element.scrollWidth; 261 | } 262 | 263 | if (colorProps.indexOf(property) > -1) { 264 | const { r, g, b, a } = parseColor(val); 265 | defaultProperties[property] = [r, g, b, a]; 266 | return; 267 | } 268 | 269 | const unit = property === 'opacity' ? '' : 'px'; 270 | defaultProperties[property] = unitToNumber(`${val}${unit}`); 271 | }); 272 | 273 | return defaultProperties; 274 | }; 275 | 276 | export const getRegexGroups = (regex, string) => { 277 | let groups = []; 278 | let match = regex.exec(string); 279 | while (match) { 280 | groups.push(match[0]); 281 | match = regex.exec(string); 282 | } 283 | 284 | return groups; 285 | }; 286 | 287 | export const parseFunctionArguments = string => { 288 | const regex = regexExpressions.functionArguments(); 289 | return getRegexGroups(regex, string).map(value => value.trim()); 290 | }; 291 | 292 | export const parseEasing = easing => { 293 | if (typeof easing === 'function') { 294 | return easing; 295 | } 296 | 297 | return easings[easing]; 298 | }; 299 | 300 | export const pickFirstNotNull = (...values) => { 301 | if (!values || !values.length) return null; 302 | return values.find(val => !is.null(val)); 303 | }; 304 | 305 | export const calculateProgress = (tick, duration) => { 306 | const progress = getProgress(tick, duration); 307 | return Math.min(progress, 1); 308 | }; 309 | 310 | export const getProgress = (tick, duration) => (tick === 0 ? 0 : tick / duration); 311 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import bezier from 'bezier-easing'; 2 | import Tweenful from './Tweenful'; 3 | import Observer from './Observer'; 4 | import ObserverGroup from './ObserverGroup'; 5 | import SVG from './SVG'; 6 | import { percentage } from './helpers'; 7 | import { elastic } from './easings'; 8 | 9 | export { SVG, Observer, ObserverGroup, percentage, bezier, elastic }; 10 | 11 | export default Tweenful; 12 | -------------------------------------------------------------------------------- /src/parser/index.js: -------------------------------------------------------------------------------- 1 | import parseColor from 'color-parser'; 2 | import { colorProps, transformProps, validKeyframeProps } from '../helpers/constants'; 3 | import { 4 | is, 5 | getSvgElLength, 6 | normalizeTweenUnit, 7 | getValidDOMProperties, 8 | getAnimatableProperties, 9 | unitToNumber, 10 | getStartingValues, 11 | pickFirstNotNull, 12 | parseEasing 13 | } from '../helpers'; 14 | 15 | class Parser { 16 | parse(el, options, transformFrom) { 17 | const animations = this.parseOptions(el, options, transformFrom); 18 | const duration = Math.max(...animations.map(({ tweens: t }) => t[t.length - 1].end)); 19 | const events = this.parseEvents(options); 20 | 21 | return { 22 | duration, 23 | progress: 0, 24 | lastTick: 0, 25 | timesCompleted: 0, 26 | paused: false, 27 | delay: options.delay || 0, 28 | loop: options.loop, 29 | direction: options.direction || 'normal', 30 | animations, 31 | transformFrom, 32 | events 33 | }; 34 | } 35 | 36 | parseEvents(options) { 37 | const events = options.events || {}; 38 | const func = () => {}; 39 | 40 | const parsedEvents = { 41 | // Observer events 42 | onMountStart: events.onMountStart || func, 43 | onMountEnd: events.onMountEnd || func, 44 | onUnmountStart: events.onUnmountStart || func, 45 | onUnmountEnd: events.onUnmountEnd || func, 46 | 47 | // Tweenful events 48 | onAnimationStart: events.onAnimationStart || func, 49 | onAnimationEnd: events.onAnimationEnd || func 50 | }; 51 | 52 | return parsedEvents; 53 | } 54 | 55 | parseAnimate({ animate, duration }) { 56 | const result = []; 57 | const arr = is.array(animate) ? animate : [animate]; 58 | 59 | arr.forEach(anim => { 60 | if (typeof anim === 'function') { 61 | result.push(...anim(duration)); 62 | } else { 63 | result.push(anim); 64 | } 65 | }); 66 | 67 | return result; 68 | } 69 | 70 | parseOptions(el, options, transformFrom) { 71 | const args = { 72 | el, 73 | options, 74 | transformFrom, 75 | animatable: this.parseAnimate(options) 76 | }; 77 | 78 | return this.getAnimations(args); 79 | } 80 | 81 | parseHeightPercentage(el, tween, property) { 82 | const offset = property === 'width' ? el.scrollWidth : el.scrollHeight; 83 | const [{ value: from }] = tween.from; 84 | const [{ value: to }] = tween.to; 85 | 86 | if (from === 'auto') { 87 | tween.from[0].value = offset; 88 | tween.from[0].unit = 'px'; 89 | } 90 | 91 | if (to === 'auto') { 92 | tween.to[0].value = offset; 93 | tween.to[0].unit = 'px'; 94 | } 95 | } 96 | 97 | parseTween(el, property, animate, from, animation, missingProps, options) { 98 | const { duration, easing, endDelay, pathLength } = options; 99 | 100 | const isTransformProperty = transformProps.includes(property); 101 | const isPropertyTweenable = !missingProps.includes(property); 102 | const isColor = colorProps.includes(property); 103 | const delay = Math.max(0, options.delay); 104 | const end = duration + delay + endDelay; 105 | const tween = { 106 | duration, 107 | easing: parseEasing(easing), 108 | startDelay: delay, 109 | endDelay 110 | }; 111 | 112 | if (isPropertyTweenable && property === 'strokeDashoffset') { 113 | const [from, to] = animate['strokeDashoffset']; 114 | tween.from = unitToNumber(`${(from / 100) * pathLength}`); 115 | tween.to = unitToNumber(`${(to / 100) * pathLength}`); 116 | tween.path = true; 117 | } 118 | 119 | if (isPropertyTweenable && isColor) { 120 | tween.color = true; 121 | tween.to = tween.from; 122 | 123 | if (animate[property]) { 124 | const { r, g, b, a } = parseColor(animate[property]); 125 | tween.to = [r, g, b, a]; 126 | } 127 | } 128 | 129 | if (is.array(animate[property])) { 130 | const [from, to] = animate[property]; 131 | tween.from = unitToNumber(from); 132 | tween.to = unitToNumber(to); 133 | 134 | const isAuto = [from, to].includes('auto'); 135 | const isAutoProp = ['height', 'width'].includes(property); 136 | 137 | if (isAutoProp && isAuto) { 138 | this.parseHeightPercentage(el, tween); 139 | } 140 | } 141 | 142 | if (animation) { 143 | // tween is already part of an animation 144 | const lastTween = animation.tweens[animation.tweens.length - 1]; 145 | tween.from = tween.from || lastTween.to; 146 | tween.to = 147 | tween.to || (isPropertyTweenable ? unitToNumber(animate[property] || 0) : lastTween.to); 148 | tween.start = lastTween.end + tween.startDelay; 149 | tween.end = lastTween.end + end; 150 | 151 | if (!isTransformProperty) { 152 | normalizeTweenUnit(el, tween); 153 | } 154 | 155 | return tween; 156 | } 157 | 158 | tween.from = tween.from || from[property]; 159 | tween.to = 160 | tween.to || (is.null(animate[property]) ? from[property] : unitToNumber(animate[property])); 161 | tween.start = 0 + tween.startDelay; 162 | tween.end = end; 163 | 164 | if (!isTransformProperty) { 165 | normalizeTweenUnit(el, tween); 166 | } 167 | 168 | return tween; 169 | } 170 | 171 | getAnimations({ el, options, animatable, transformFrom }) { 172 | const animatableProps = getAnimatableProperties(animatable); 173 | const from = getStartingValues(el, transformFrom, animatableProps); 174 | const animations = []; 175 | 176 | animatable.forEach(animate => { 177 | const domProps = getValidDOMProperties(Object.keys(animate), validKeyframeProps); 178 | const missingProps = animatableProps.filter(p => !domProps.includes(p)); 179 | const iterableDOMProperties = [...domProps, ...missingProps]; 180 | 181 | const config = { 182 | loop: options.loop, 183 | duration: pickFirstNotNull(animate.duration, options.duration / animatable.length), 184 | delay: pickFirstNotNull(animate.delay, options.delay, 0), 185 | endDelay: pickFirstNotNull(animate.endDelay, options.endDelay, 0), 186 | easing: animate.easing || options.easing, 187 | // supply pathLength for path animations 188 | pathLength: is.svg(el) ? getSvgElLength(el) : 0 189 | }; 190 | 191 | iterableDOMProperties.forEach(property => { 192 | const animation = animations.find(anim => anim.property === property); 193 | const tween = this.parseTween(el, property, animate, from, animation, missingProps, config); 194 | 195 | if (animation) { 196 | animation.tweens.push(tween); 197 | } else { 198 | animations.push({ 199 | property, 200 | tweens: [tween] 201 | }); 202 | } 203 | }); 204 | }); 205 | 206 | return animations; 207 | } 208 | } 209 | 210 | export default new Parser(); 211 | -------------------------------------------------------------------------------- /webpack.dev.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 4 | 5 | module.exports = { 6 | mode: 'development', 7 | entry: './src/index.js', 8 | devtool: 'cheap-module-source-map', 9 | 10 | output: { 11 | path: path.resolve(__dirname, 'dist'), 12 | filename: 'js/react-tweenful.js', 13 | library: 'Tweenful', 14 | libraryTarget: 'commonjs2' 15 | }, 16 | 17 | plugins: [ 18 | new CleanWebpackPlugin({}), 19 | new webpack.DefinePlugin({ 20 | 'process.env.NODE_ENV': JSON.stringify('development') 21 | }) 22 | ], 23 | 24 | resolve: { 25 | extensions: ['.js', '.jsx', '.json'], 26 | alias: { 27 | src: path.resolve(__dirname, 'src'), 28 | samples: path.resolve(__dirname, 'site') 29 | } 30 | }, 31 | 32 | module: { 33 | rules: [ 34 | { 35 | test: /\.(js|jsx)$/, 36 | use: ['babel-loader'], 37 | include: /src/ 38 | }, 39 | { 40 | test: /\.(js|jsx)$/, 41 | use: ['eslint-loader'], 42 | include: /src/ 43 | }, 44 | { 45 | test: /\.(css|scss)$/, 46 | use: [{ loader: 'css-loader' }, { loader: 'sass-loader' }], 47 | include: /src/ 48 | } 49 | ] 50 | }, 51 | 52 | externals: { 53 | react: { 54 | commonjs: 'react', 55 | commonjs2: 'react', 56 | amd: 'react', 57 | root: 'React' 58 | }, 59 | 'react-dom': { 60 | commonjs: 'react-dom', 61 | commonjs2: 'react-dom', 62 | amd: 'react-dom', 63 | root: 'ReactDOM' 64 | } 65 | } 66 | }; 67 | -------------------------------------------------------------------------------- /webpack.prod.js: -------------------------------------------------------------------------------- 1 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 2 | const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin'); 3 | const TerserPlugin = require('terser-webpack-plugin'); 4 | const webpack = require('webpack'); 5 | const path = require('path'); 6 | 7 | module.exports = { 8 | mode: 'production', 9 | entry: './src/index.js', 10 | devtool: 'source-map', 11 | 12 | output: { 13 | path: path.resolve(__dirname, 'dist'), 14 | filename: 'react-tweenful.js', 15 | library: 'Tweenful', 16 | libraryTarget: 'commonjs2' 17 | }, 18 | 19 | optimization: { 20 | minimizer: [new TerserPlugin(), new OptimizeCSSAssetsPlugin({})] 21 | }, 22 | 23 | resolve: { 24 | extensions: ['.js', '.scss'], 25 | alias: { 26 | src: path.resolve(__dirname, 'src'), 27 | samples: path.resolve(__dirname, 'site') 28 | } 29 | }, 30 | 31 | module: { 32 | rules: [ 33 | { 34 | test: /\.(js|jsx)$/, 35 | use: ['babel-loader'], 36 | include: /src/ 37 | }, 38 | { 39 | test: /\.(js|jsx)$/, 40 | use: ['eslint-loader'], 41 | include: /src/ 42 | } 43 | ] 44 | }, 45 | 46 | plugins: [ 47 | new CleanWebpackPlugin({ 48 | watch: true, 49 | beforeEmit: true 50 | }), 51 | new webpack.DefinePlugin({ 52 | 'process.env.NODE_ENV': JSON.stringify('production') 53 | }) 54 | ], 55 | 56 | externals: { 57 | 'react': { 58 | commonjs: 'react', 59 | commonjs2: 'react', 60 | amd: 'react', 61 | root: 'React' 62 | }, 63 | 'react-dom': { 64 | commonjs: 'react-dom', 65 | commonjs2: 'react-dom', 66 | amd: 'react-dom', 67 | root: 'ReactDOM' 68 | } 69 | } 70 | }; 71 | -------------------------------------------------------------------------------- /webpack.site.dev.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 4 | const webpack = require('webpack'); 5 | 6 | module.exports = { 7 | mode: 'development', 8 | 9 | entry: './site/index.js', 10 | 11 | devtool: 'cheap-module-source-map', 12 | 13 | output: { 14 | publicPath: "/", 15 | path: path.resolve(__dirname, 'dist'), 16 | filename: 'site-bundle.js' 17 | }, 18 | 19 | devServer: { 20 | open: true, 21 | compress: true, 22 | historyApiFallback: true, 23 | contentBase: path.join(__dirname, 'dist') 24 | }, 25 | 26 | resolve: { 27 | alias: { 28 | 'react-tweenful': path.resolve(__dirname, 'src'), 29 | 'site': path.resolve(__dirname, 'site') 30 | }, 31 | extensions: ['.js', '.css', '.scss'] 32 | }, 33 | 34 | module: { 35 | rules: [ 36 | { 37 | test: /\.(js|jsx)$/, 38 | use: ['babel-loader'], 39 | exclude: /node_modules/ 40 | }, 41 | { 42 | test: /\.(js|jsx)$/, 43 | use: ['eslint-loader'], 44 | include: /samples/ 45 | }, 46 | { 47 | test: /\.(css|scss)$/, 48 | use: [{ loader: 'style-loader' }, { loader: 'css-loader' }, { loader: 'sass-loader' }] 49 | }, 50 | { 51 | test: /\.(png|svg|jpg|gif)$/, 52 | use: ['file-loader'] 53 | }, 54 | { 55 | test: /\.(woff|woff2|eot|ttf|otf)$/, 56 | use: ['file-loader'] 57 | } 58 | ] 59 | }, 60 | 61 | plugins: [ 62 | new CleanWebpackPlugin({ 63 | watch: true, 64 | beforeEmit: true 65 | }), 66 | new HtmlWebpackPlugin({ 67 | inject: true, 68 | template: './site/html/index.html' 69 | }), 70 | new webpack.DefinePlugin({ 71 | 'process.env.NODE_ENV': JSON.stringify('development') 72 | }), 73 | new webpack.NamedModulesPlugin() 74 | ] 75 | }; -------------------------------------------------------------------------------- /webpack.site.prod.js: -------------------------------------------------------------------------------- 1 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 2 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 3 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 4 | const CopyPlugin = require('copy-webpack-plugin'); 5 | const TerserPlugin = require('terser-webpack-plugin'); 6 | const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin'); 7 | const webpack = require('webpack'); 8 | const path = require('path'); 9 | 10 | module.exports = { 11 | mode: 'production', 12 | entry: './site/index.js', 13 | 14 | output: { 15 | path: path.resolve(__dirname, 'dist'), 16 | filename: '[name].js' 17 | }, 18 | 19 | resolve: { 20 | alias: { 21 | 'react-tweenful': path.resolve(__dirname, 'src'), 22 | 'site': path.resolve(__dirname, 'site') 23 | }, 24 | extensions: ['.js', '.css', '.scss'] 25 | }, 26 | 27 | module: { 28 | rules: [ 29 | { 30 | test: /\.(js|jsx)$/, 31 | use: ['babel-loader'], 32 | exclude: /node_modules/ 33 | }, 34 | { 35 | test: /\.(js|jsx)$/, 36 | use: ['eslint-loader'], 37 | include: /samples/ 38 | }, 39 | { 40 | test: /\.(css|scss)$/, 41 | use: [ 42 | { loader: MiniCssExtractPlugin.loader }, 43 | { loader: 'css-loader' }, 44 | { loader: 'sass-loader' } 45 | ] 46 | }, 47 | { 48 | test: /\.(png|svg|jpg|gif)$/, 49 | use: ['file-loader'] 50 | }, 51 | { 52 | test: /\.(woff|woff2|eot|ttf|otf)$/, 53 | use: ['file-loader'] 54 | } 55 | ] 56 | }, 57 | 58 | optimization: { 59 | minimizer: [ 60 | new TerserPlugin(), 61 | new OptimizeCSSAssetsPlugin({}) 62 | ], 63 | splitChunks: { 64 | cacheGroups: { 65 | commons: { 66 | test: /[\\/]node_modules[\\/]/, 67 | name: 'vendors', 68 | chunks: 'all' 69 | } 70 | } 71 | } 72 | }, 73 | 74 | plugins: [ 75 | new CleanWebpackPlugin({ 76 | watch: true, 77 | beforeEmit: true 78 | }), 79 | new CopyPlugin([{ from: './site/html/404.html', to: './' }]), 80 | new HtmlWebpackPlugin({ 81 | inject: true, 82 | template: './site/html/index.html' 83 | }), 84 | new TerserPlugin(), 85 | new webpack.DefinePlugin({ 86 | 'process.env.NODE_ENV': JSON.stringify('production') 87 | }), 88 | new MiniCssExtractPlugin({ 89 | filename: '[name].css' 90 | }) 91 | ] 92 | }; --------------------------------------------------------------------------------