├── .circleci
└── config.yml
├── .eslintrc.js
├── .github
├── FUNDING.yml
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
└── workflows
│ ├── compressedSize.yml
│ └── unitTest.yml
├── .gitignore
├── .npmignore
├── .prettierrc
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── jest.config.js
├── logo.png
├── package.json
├── rollup.config.js
├── setup.ts
├── src
├── animate.test.tsx
├── animate.tsx
├── animateGroup.test.tsx
├── animateGroup.tsx
├── animateKeyframes.test.tsx
├── animateKeyframes.tsx
├── constants
│ └── index.ts
├── index.ts
├── logic
│ ├── __snapshots__
│ │ ├── createStyle.test.ts.snap
│ │ └── createTag.test.ts.snap
│ ├── createStyle.test.ts
│ ├── createStyle.ts
│ ├── createTag.test.ts
│ ├── createTag.ts
│ ├── deleteRules.test.ts
│ └── deleteRules.ts
├── types.ts
├── useAnimate.test.tsx
├── useAnimate.ts
├── useAnimateGroup.test.tsx
├── useAnimateGroup.ts
├── useAnimateKeyframes.test.tsx
├── useAnimateKeyframes.ts
└── utils
│ ├── calculateTotalDuration.test.ts
│ ├── calculateTotalDuration.ts
│ ├── camelCaseToDash.test.ts
│ ├── camelCaseToDash.ts
│ ├── createArrayWithNumbers.ts
│ ├── createRandomName.ts
│ ├── getPauseState.test.ts
│ ├── getPauseState.ts
│ ├── getSequenceId.test.ts
│ ├── getSequenceId.ts
│ ├── isUndefined.test.ts
│ ├── isUndefined.ts
│ ├── secToMs.test.ts
│ └── secToMs.ts
├── tsconfig.jest.json
├── tsconfig.json
├── website
└── README.md
└── yarn.lock
/.circleci/config.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | jobs:
3 | build:
4 | docker:
5 | - image: circleci/node:lts
6 |
7 | working_directory: ~/repo
8 |
9 | steps:
10 | - checkout
11 |
12 | - restore_cache:
13 | keys:
14 | - v1-dependencies-{{ checksum "package.json" }}
15 | - v1-dependencies-
16 |
17 | - run: npm install
18 |
19 | - save_cache:
20 | paths:
21 | - node_modules
22 | key: v1-dependencies-{{ checksum "package.json" }}
23 |
24 | - run: npm run test
25 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | parser: '@typescript-eslint/parser',
3 | extends: [
4 | 'plugin:@typescript-eslint/recommended',
5 | 'plugin:react/recommended',
6 | ],
7 | parserOptions: {
8 | ecmaVersion: 2018,
9 | sourceType: 'module',
10 | },
11 | plugins: ['react-hooks'],
12 | rules: {
13 | curly: 'error',
14 | '@typescript-eslint/ban-ts-comment': 'warn',
15 | '@typescript-eslint/indent': 'off',
16 | '@typescript-eslint/no-non-null-assertion': 'off',
17 | '@typescript-eslint/no-empty-function': 'off',
18 | '@typescript-eslint/no-explicit-any': 'off',
19 | '@typescript-eslint/explicit-function-return-type': 'off',
20 | '@typescript-eslint/no-object-literal-type-assertion': 'off',
21 | 'react-hooks/rules-of-hooks': 'error',
22 | 'react-hooks/exhaustive-deps': 'warn',
23 | 'react/display-name': 'warn',
24 | 'no-console': 'error',
25 | },
26 | overrides: [
27 | {
28 | files: ['*.test.ts', '*.test.tsx'],
29 | rules: {
30 | // Allow testing runtime errors to suppress TS errors
31 | '@typescript-eslint/ban-ts-ignore': 'off',
32 | },
33 | },
34 | ],
35 | settings: {
36 | react: {
37 | pragma: 'React',
38 | version: 'detect',
39 | },
40 | },
41 | };
42 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: bluebill1049
4 | patreon: bluebill1049
5 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Desktop (please complete the following information):**
27 | - OS: [e.g. iOS]
28 | - Browser [e.g. chrome, safari]
29 | - Version [e.g. 22]
30 |
31 | **Smartphone (please complete the following information):**
32 | - Device: [e.g. iPhone6]
33 | - OS: [e.g. iOS8.1]
34 | - Browser [e.g. stock browser, safari]
35 | - Version [e.g. 22]
36 |
37 | **Additional context**
38 | Add any other context about the problem here.
39 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/.github/workflows/compressedSize.yml:
--------------------------------------------------------------------------------
1 | name: Package Size
2 |
3 | on: [pull_request]
4 |
5 | jobs:
6 | build:
7 | runs-on: ubuntu-latest
8 |
9 | steps:
10 | - uses: actions/checkout@v2
11 | - uses: preactjs/compressed-size-action@v1
12 | with:
13 | repo-token: '${{ secrets.GITHUB_TOKEN }}'
14 |
--------------------------------------------------------------------------------
/.github/workflows/unitTest.yml:
--------------------------------------------------------------------------------
1 | name: Unit Test / Lint
2 |
3 | on: [pull_request]
4 |
5 | jobs:
6 | build:
7 | runs-on: ubuntu-latest
8 |
9 | steps:
10 | - uses: actions/checkout@v2
11 | - name: Run install
12 | run: npm install
13 | - name: Run test
14 | run: npm run lint && npm run test
15 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 |
8 | # Runtime data
9 | pids
10 | *.pid
11 | *.seed
12 | *.pid.lock
13 |
14 | # Directory for instrumented libs generated by jscoverage/JSCover
15 | lib-cov
16 |
17 | # Coverage directory used by tools like istanbul
18 | coverage
19 |
20 | # nyc test coverage
21 | .nyc_output
22 |
23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
24 | .grunt
25 |
26 | # Bower dependency directory (https://bower.io/)
27 | bower_components
28 |
29 | # node-waf configuration
30 | .lock-wscript
31 |
32 | # Compiled binary addons (http://nodejs.org/api/addons.html)
33 | build/Release
34 |
35 | # Dependency directories
36 | node_modules/
37 | example/node_modules/
38 | example/build/
39 | jspm_packages/
40 |
41 | # Typescript v1 declaration files
42 | typings/
43 |
44 | # Optional npm cache directory
45 | .npm
46 |
47 | # Optional eslint cache
48 | .eslintcache
49 |
50 | # Optional REPL history
51 | .node_repl_history
52 |
53 | # Output of 'npm pack'
54 | *.tgz
55 |
56 | # Yarn Integrity file
57 | .yarn-integrity
58 |
59 | # dotenv environment variables file
60 | .env
61 |
62 | .DS_Store
63 |
64 | /dist
65 | /lib
66 | .idea/
67 | .coveralls.yml
68 | .rpt2_cache/
69 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | example/
2 | .circleci/
3 | src/
4 | test/
5 | .babelrc
6 | .flowconfig
7 | .npmignore
8 | yarn-error.log
9 | yarn.lock
10 | CONTRIBUTING.md
11 | .eslintrc
12 | jestConfig.json
13 | .idea/
14 | rollup.config.js
15 | .coveralls.yml
16 | coverage/
17 | /lib
18 | jest.config.js
19 | rollup.config.js
20 | setup.ts
21 | tsconfig.json
22 | tsconfig.jest.json
23 | /website
24 | .eslintrc.js
25 | jest.config.js
26 | logo.png
27 | .rpt2_cache
28 | .github
29 | .prettierrc
30 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "trailingComma": "all",
3 | "singleQuote": true,
4 | "printWidth": 80,
5 | "tabWidth": 2
6 | }
7 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to React Simple Animate
2 |
3 | ## Pull Requests
4 |
5 | Welcome your pull requests for documentation and code. 🙏
6 |
7 | 1. Fork the repo and create your branch from `master`.
8 | 2. If you've added code that should be tested.
9 | 3. If you've changed APIs, update the documentation.
10 | 4. Ensure the test suite passes.
11 | 5. Make sure your code lints.
12 | 6. Make sure your code pass flow type check.
13 |
14 | ## Coding Style
15 |
16 | * 2 spaces for indentation rather than tabs
17 | * See .eslintrc for the gory details.
18 | * Run prettier if you can :)
19 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2017 Bill Luo
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |

2 |
3 | React Simple Animate
4 |
5 | React UI animation made easy
6 |
7 |
8 |
9 | [](https://www.npmjs.com/package/react-simple-animate)
10 | [](https://www.npmjs.com/package/react-simple-animate)
11 | [](https://bundlephobia.com/result?p=react-simple-animate)
12 | [](https://coveralls.io/github/bluebill1049/react-simple-animate?branch=master)
13 |
14 |
15 |
16 | ## Features
17 |
18 | - Animation from style A to B
19 | - CSS keyframes animation
20 | - Chain up animation sequences
21 | - Tiny size without other dependency
22 |
23 | ## Install
24 |
25 | $ npm install react-simple-animate
26 |
27 | ## [Docs](https://react-simple-animate.now.sh/)
28 |
29 | - [Getting started](https://react-simple-animate.now.sh/basics)
30 | - [Animate](https://react-simple-animate.now.sh/animate)
31 | - [AnimateKeyframes](https://react-simple-animate.now.sh/animate-keyframes)
32 | - [AnimateGroup](https://react-simple-animate.now.sh/animate-group)
33 | - [Custom Hooks](https://react-simple-animate.now.sh/hooks)
34 | - [Advanced](https://react-simple-animate.now.sh/advanced)
35 |
36 | ## Quickstart
37 |
38 | #### Components
39 |
40 | ```jsx
41 | import React from "react";
42 | import { Animate, AnimateKeyframes, AnimateGroup } from "react-simple-animate";
43 |
44 | export default () => (
45 | <>
46 | {/* animate individual element. */}
47 |
48 | React simple animate
49 |
50 |
51 | {/* animate keyframes with individual element. */}
52 |
57 | React simple animate with keyframes
58 |
59 |
60 | {/* animate group of animation in sequences */}
61 |
62 |
63 | first
64 |
65 |
66 | second
67 |
68 |
69 | third
70 |
71 |
72 | >
73 | );
74 |
75 | ```
76 |
77 | #### Hooks
78 |
79 | ```jsx
80 | import react from 'react';
81 | import { useAnimate, useAnimateKeyframes, useAnimateGroup } from 'react-simple-animate';
82 |
83 | export const useAnimateExample = () => {
84 | const { style, play } = useAnimate({ start: { opacity: 0 }, end: { opacity: 1 } });
85 | useEffect(() => play(true), []);
86 |
87 | return useAnimate
;
88 | };
89 |
90 | export const useAnimateKeyframesExample = () => {
91 | const { style, play } = useAnimateKeyframes({
92 | keyframes: ['opacity: 0', 'opacity: 1'],
93 | iterationCount: 4
94 | });
95 | useEffect(() => play(true), []);
96 |
97 | return useAnimate
;
98 | };
99 |
100 | export const useAnimateGroup = () => {
101 | const { styles = [], play } = useAnimateGroup({
102 | sequences: [
103 | { start: { opacity: 1 }, end: { opacity: 0 } },
104 | { start: { background: "red" }, end: { background: "blue" } }
105 | ]
106 | });
107 | useEffect(() => play(true), []);
108 |
109 | return {styles.map(style => useAnimateGroup
)};
110 | };
111 | ```
112 |
113 | ## By the makers of BEEKAI
114 |
115 | We also make [BEEKAI](https://www.beekai.com/). Build the next-generation forms with modern technology and best in class user experience and accessibility.
116 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | roots: ['/src'],
3 | transform: {
4 | '^.+\\.tsx?$': 'ts-jest',
5 | },
6 | globals: {
7 | 'ts-jest': {
8 | tsConfig: 'tsconfig.jest.json',
9 | },
10 | },
11 | testMatch: ['**/?(*.)+(spec|test).ts?(x)'],
12 | transformIgnorePatterns: ['[/\\\\]node_modules[/\\\\].+\\.(js|jsx)$'],
13 | setupFiles: ['/setup.ts'],
14 | moduleFileExtensions: ['ts', 'tsx', 'js'],
15 | };
16 |
--------------------------------------------------------------------------------
/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/beekai-oss/react-simple-animate/46673f9fcdf3e153456b7d862de3c89780615f71/logo.png
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-simple-animate",
3 | "version": "3.5.3",
4 | "description": "react simple animate",
5 | "main": "dist/index.js",
6 | "keywords": [
7 | "react",
8 | "animation",
9 | "transition-animation",
10 | "animate-css",
11 | "animation-controller",
12 | "animation-sequence",
13 | "keyframes-animation",
14 | "animate"
15 | ],
16 | "scripts": {
17 | "clean": "rm -rf dist/*",
18 | "build": "rollup -c",
19 | "watch": "tsc --watch",
20 | "release": "npm version",
21 | "lint": "eslint ./src --ext .jsx,.ts --ignore-pattern *.test.ts",
22 | "coverage": "jest --coverage --coverageReporters=text-lcov | coveralls",
23 | "postrelease": "yarn publish && git push --follow-tags",
24 | "test": "jest --coverage",
25 | "test:watch": "yarn test -- --watchAll",
26 | "prepublish": "yarn test && yarn run clean && yarn build"
27 | },
28 | "repository": "https://github.com/bluebill1049/react-simple-animation.git",
29 | "homepage": "https://react-simple-animate.now.sh",
30 | "author": "beier luo",
31 | "license": "MIT",
32 | "bugs": {
33 | "url": "https://github.com/bluebill1049/react-simple-animate/issues"
34 | },
35 | "devDependencies": {
36 | "@types/enzyme": "^3.10.5",
37 | "@types/enzyme-adapter-react-16": "^1.0.6",
38 | "@types/jest": "^26.0.1",
39 | "@types/react": "^16.9.35",
40 | "@types/react-test-renderer": "^16.9.2",
41 | "@typescript-eslint/eslint-plugin": "^3.0.0",
42 | "@typescript-eslint/parser": "^3.0.0",
43 | "coveralls": "^3.0.3",
44 | "enzyme": "^3.11.0",
45 | "enzyme-adapter-react-16": "^1.15.2",
46 | "eslint": "^7.0.0",
47 | "eslint-plugin-react": "^7.20.0",
48 | "eslint-plugin-react-hooks": "^4.0.2",
49 | "husky": "^4.2.5",
50 | "jest": "^26.0.1",
51 | "lint-staged": "^10.2.6",
52 | "npm-run-all": "^4.1.5",
53 | "prettier": "^2.2.1",
54 | "react": "^16.13.1",
55 | "react-dom": "^16.13.1",
56 | "react-test-renderer": "^16.13.1",
57 | "rollup": "^2.10.7",
58 | "rollup-plugin-typescript2": "^0.27.1",
59 | "ts-jest": "^26.0.0",
60 | "typescript": "^3.9.3",
61 | "uglify-es": "^3.3.9"
62 | },
63 | "peerDependencies": {
64 | "react-dom": "^16.8.0 || ^17 || ^18 || ^19"
65 | },
66 | "husky": {
67 | "hooks": {
68 | "pre-commit": "lint-staged"
69 | }
70 | },
71 | "lint-staged": {
72 | "*.{tsx,ts}": [
73 | "eslint --fix",
74 | "prettier --config ./.prettierrc --write"
75 | ]
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | import typescript from 'rollup-plugin-typescript2';
2 |
3 | export default {
4 | input: 'src/index.ts',
5 | plugins: [
6 | typescript(),
7 | ],
8 | external: ['react'],
9 | output: [
10 | {
11 | file: 'dist/index.js',
12 | format: 'cjs',
13 | },
14 | {
15 | file: 'dist/index.es.js',
16 | format: 'es',
17 | },
18 | ],
19 | };
20 |
--------------------------------------------------------------------------------
/setup.ts:
--------------------------------------------------------------------------------
1 | import * as Enzyme from 'enzyme';
2 | import * as Adapter from 'enzyme-adapter-react-16';
3 |
4 | Enzyme.configure({ adapter: new Adapter() });
5 |
--------------------------------------------------------------------------------
/src/animate.test.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as ReactDOM from 'react-dom';
3 | import { act } from 'react-dom/test-utils';
4 | import Animate from '../src/animate';
5 |
6 | jest.useFakeTimers();
7 |
8 | describe('Animate', () => {
9 | let container;
10 |
11 | beforeEach(() => {
12 | container = document.createElement('div');
13 | document.body.appendChild(container);
14 | });
15 |
16 | afterEach(() => {
17 | document.body.removeChild(container);
18 | container = null;
19 | });
20 |
21 | it('should start with start style', () => {
22 | act(() => {
23 | ReactDOM.render(bill, container);
24 | });
25 |
26 | const div = container.querySelector('div');
27 | expect(div.innerHTML).toEqual('bill');
28 | expect(div.style.transition).toEqual('all 0.3s linear 0s');
29 | expect(div.style.opacity).toBe('');
30 | });
31 |
32 | it('should end with end style', () => {
33 | act(() => {
34 | ReactDOM.render(
35 |
36 | bill
37 | ,
38 | container,
39 | );
40 | });
41 |
42 | const div = container.querySelector('div');
43 | expect(div.innerHTML).toEqual('bill');
44 | expect(div.style.opacity).toEqual('0');
45 | });
46 |
47 | it('should finish with complete style', () => {
48 | act(() => {
49 | ReactDOM.render(
50 |
51 | bill
52 | ,
53 | container,
54 | );
55 | });
56 |
57 | const div = container.querySelector('div');
58 | expect(div.style.opacity).toEqual('0');
59 |
60 | jest.runAllTimers();
61 | expect(div.style.background).toEqual('red');
62 | });
63 |
64 | it('should render correctly with render props', () => {
65 | act(() => {
66 | ReactDOM.render(
67 | // @ts-ignore
68 | render props}>
69 | bill
70 | ,
71 | container,
72 | );
73 | });
74 |
75 | const div = container.querySelector('span');
76 | expect(div.style.opacity).toEqual('0');
77 | expect(div.innerHTML).toEqual('render props');
78 | });
79 | });
80 |
--------------------------------------------------------------------------------
/src/animate.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { AnimateContext } from './animateGroup';
3 | import secToMs from './utils/secToMs';
4 | import getSequenceId from './utils/getSequenceId';
5 | import isUndefined from './utils/isUndefined';
6 | import { ALL, DEFAULT_DURATION, DEFAULT_EASE_TYPE } from './constants';
7 | import { AnimationProps } from './types';
8 |
9 | export default function Animate(props: AnimationProps): React.ReactElement {
10 | const {
11 | play,
12 | children,
13 | render,
14 | start,
15 | end,
16 | complete = '',
17 | onComplete,
18 | delay = 0,
19 | duration = DEFAULT_DURATION,
20 | easeType = DEFAULT_EASE_TYPE,
21 | sequenceId,
22 | sequenceIndex,
23 | } = props;
24 | const onCompleteTimeRef = React.useRef();
25 | const [style, setStyle] = React.useState(start || {});
26 | const { register, animationStates = {} } = React.useContext(AnimateContext);
27 | const id = getSequenceId(sequenceIndex, sequenceId);
28 |
29 | React.useEffect(() => {
30 | if ((!isUndefined(sequenceIndex) && sequenceIndex >= 0) || sequenceId) {
31 | register(props);
32 | }
33 | // eslint-disable-next-line react-hooks/exhaustive-deps
34 | }, []);
35 |
36 | React.useEffect(() => {
37 | const animationState = animationStates[id] || {};
38 |
39 | setStyle({
40 | ...(play || animationState.play ? end : start),
41 | transition: `${ALL} ${duration}s ${easeType} ${
42 | animationState.delay || delay
43 | }s`,
44 | });
45 |
46 | if (play && (complete || onComplete)) {
47 | onCompleteTimeRef.current = setTimeout(() => {
48 | complete && setStyle(complete);
49 | onComplete && onComplete();
50 | }, secToMs((animationState.delay || delay) + duration));
51 | }
52 |
53 | return () =>
54 | onCompleteTimeRef.current && clearTimeout(onCompleteTimeRef.current);
55 | }, [
56 | id,
57 | animationStates,
58 | play,
59 | duration,
60 | easeType,
61 | delay,
62 | onComplete,
63 | start,
64 | end,
65 | complete,
66 | ]);
67 |
68 | return render ? render({ style }) : {children}
;
69 | }
70 |
--------------------------------------------------------------------------------
/src/animateGroup.test.tsx:
--------------------------------------------------------------------------------
1 | import AnimateGroup from './animateGroup';
2 | import * as ReactDOM from 'react-dom';
3 | import { act } from 'react-dom/test-utils';
4 | import * as React from 'react';
5 | import { Animate } from './index';
6 | import AnimateKeyframes from './animateKeyframes';
7 |
8 | describe('AnimateGroup', () => {
9 | let container;
10 |
11 | beforeEach(() => {
12 | container = document.createElement('div');
13 | document.body.appendChild(container);
14 | });
15 |
16 | afterEach(() => {
17 | document.body.removeChild(container);
18 | container = null;
19 | });
20 |
21 | it('should render correctly', () => {
22 | act(() => {
23 | ReactDOM.render(
24 |
25 |
26 | test
27 |
28 |
29 | what
30 |
31 | ,
32 | container,
33 | );
34 | });
35 |
36 | act(() => {
37 | expect(container.querySelectorAll('div')[0].style.opacity).toEqual('0');
38 | });
39 | });
40 | });
41 |
--------------------------------------------------------------------------------
/src/animateGroup.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import calculateTotalDuration from './utils/calculateTotalDuration';
3 | import getSequenceId from './utils/getSequenceId';
4 | import isUndefined from './utils/isUndefined';
5 | import { DEFAULT_DURATION } from './constants';
6 | import { Sequences, AnimationProps, AnimateKeyframesProps } from './types';
7 |
8 | export interface Props {
9 | play: boolean;
10 | sequences?: Sequences;
11 | children?: any;
12 | }
13 |
14 | type SequenceId = number | string;
15 | type PartialSequence = {
16 | play: boolean;
17 | pause: boolean;
18 | delay: number;
19 | controlled: boolean;
20 | };
21 | type AnimationStates = Record;
22 | type Register = (data: AnimationProps | AnimateKeyframesProps) => void;
23 |
24 | interface IAnimationContext {
25 | animationStates: AnimationStates;
26 | register: Register;
27 | }
28 | export const AnimateContext = React.createContext({
29 | animationStates: {},
30 | register: () => {},
31 | });
32 |
33 | export default function AnimateGroup({
34 | play,
35 | sequences = [],
36 | children,
37 | }: Props): React.ReactElement {
38 | const [animationStates, setAnimationStates] = React.useState(
39 | {},
40 | );
41 | const animationsRef = React.useRef<{
42 | [key: string]: AnimationProps | AnimateKeyframesProps;
43 | }>({});
44 |
45 | const register: Register = React.useCallback((data) => {
46 | const { sequenceIndex, sequenceId } = data;
47 | if (!isUndefined(sequenceId) || !isUndefined(sequenceIndex)) {
48 | animationsRef.current[getSequenceId(sequenceIndex, sequenceId)] = data;
49 | }
50 | }, []);
51 |
52 | React.useEffect(() => {
53 | const sequencesToAnimate =
54 | Array.isArray(sequences) && sequences.length
55 | ? sequences
56 | : Object.values(animationsRef.current);
57 | const localAnimationState: AnimationStates = {};
58 |
59 | (play ? sequencesToAnimate : [...sequencesToAnimate].reverse()).reduce(
60 | (
61 | previous,
62 | {
63 | sequenceId,
64 | sequenceIndex,
65 | duration = DEFAULT_DURATION,
66 | delay,
67 | overlay,
68 | },
69 | currentIndex,
70 | ) => {
71 | const id = getSequenceId(sequenceIndex, sequenceId, currentIndex);
72 | const currentTotalDuration = calculateTotalDuration({
73 | duration,
74 | delay,
75 | overlay,
76 | });
77 | const totalDuration = currentTotalDuration + previous;
78 |
79 | localAnimationState[id] = {
80 | play,
81 | pause: !play,
82 | delay: (delay || 0) + previous,
83 | controlled: true,
84 | };
85 |
86 | return totalDuration;
87 | },
88 | 0,
89 | );
90 |
91 | setAnimationStates(localAnimationState);
92 | // eslint-disable-next-line react-hooks/exhaustive-deps
93 | }, [play]);
94 |
95 | return (
96 |
97 | {children}
98 |
99 | );
100 | }
101 |
--------------------------------------------------------------------------------
/src/animateKeyframes.test.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as ReactDOM from 'react-dom';
3 | import { act } from 'react-dom/test-utils';
4 | import AnimateKeyframes from './animateKeyframes';
5 |
6 | jest.mock('./utils/createRandomName', () => ({
7 | default: () => 'test',
8 | }));
9 |
10 | describe('AnimateKeyframes', () => {
11 | let container;
12 |
13 | beforeEach(() => {
14 | container = document.createElement('div');
15 | document.body.appendChild(container);
16 | });
17 |
18 | afterEach(() => {
19 | document.body.removeChild(container);
20 | container = null;
21 | });
22 |
23 | it('should render set animation', () => {
24 | act(() => {
25 | ReactDOM.render(
26 |
27 | bill
28 | ,
29 | container,
30 | );
31 | });
32 |
33 | const div = container.querySelector('div');
34 | expect(div.innerHTML).toEqual('bill');
35 |
36 | expect(div.style.animation).toEqual('0.3s linear 0s 1 normal none running test');
37 | });
38 | });
39 |
--------------------------------------------------------------------------------
/src/animateKeyframes.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { AnimateContext } from './animateGroup';
3 | import createTag from './logic/createTag';
4 | import deleteRule from './logic/deleteRules';
5 | import createRandomName from './utils/createRandomName';
6 | import getSequenceId from './utils/getSequenceId';
7 | import getPlayState from './utils/getPauseState';
8 | import {
9 | DEFAULT_DIRECTION,
10 | DEFAULT_DURATION,
11 | DEFAULT_EASE_TYPE,
12 | DEFAULT_FILLMODE,
13 | } from './constants';
14 | import { AnimateKeyframesProps } from './types';
15 |
16 | export default function AnimateKeyframes(
17 | props: AnimateKeyframesProps,
18 | ): React.ReactElement {
19 | const {
20 | children,
21 | play = false,
22 | pause = false,
23 | render,
24 | duration = DEFAULT_DURATION,
25 | delay = 0,
26 | easeType = DEFAULT_EASE_TYPE,
27 | direction = DEFAULT_DIRECTION,
28 | fillMode = DEFAULT_FILLMODE,
29 | iterationCount = 1,
30 | sequenceIndex,
31 | keyframes,
32 | sequenceId,
33 | } = props;
34 | let pauseValue;
35 | const animationNameRef = React.useRef({
36 | forward: '',
37 | reverse: '',
38 | });
39 | const controlled = React.useRef(false);
40 | const styleTagRef = React.useRef<
41 | Record<'forward' | 'reverse', HTMLStyleElement | null>
42 | >({
43 | forward: null,
44 | reverse: null,
45 | });
46 | const id = getSequenceId(sequenceIndex, sequenceId);
47 | const { register, animationStates = {} } = React.useContext(AnimateContext);
48 | const animateState = animationStates[id] || {};
49 | const [, forceUpdate] = React.useState(false);
50 |
51 | React.useEffect(() => {
52 | const styleTag = styleTagRef.current;
53 | const animationName = animationNameRef.current;
54 |
55 | animationNameRef.current.forward = createRandomName();
56 | let result = createTag({
57 | animationName: animationNameRef.current.forward,
58 | keyframes,
59 | });
60 | styleTagRef.current.forward = result.styleTag;
61 |
62 | animationNameRef.current.reverse = createRandomName();
63 | result = createTag({
64 | animationName: animationNameRef.current.reverse,
65 | keyframes: keyframes.reverse(),
66 | });
67 | styleTagRef.current.reverse = result.styleTag;
68 |
69 | register(props);
70 |
71 | if (play) {
72 | forceUpdate(true);
73 | }
74 |
75 | return () => {
76 | deleteRule(styleTag.forward?.sheet, animationName.forward);
77 | deleteRule(styleTag.reverse?.sheet, animationName.reverse);
78 | };
79 | // eslint-disable-next-line react-hooks/exhaustive-deps
80 | }, []);
81 |
82 | if (animateState.controlled && !controlled.current) {
83 | pauseValue = animateState.pause;
84 | if (!animateState.pause) {
85 | controlled.current = true;
86 | }
87 | } else {
88 | pauseValue = pause;
89 | }
90 |
91 | const style = {
92 | animation: `${duration}s ${easeType} ${
93 | animateState.delay || delay
94 | }s ${iterationCount} ${direction} ${fillMode} ${getPlayState(pauseValue)} ${
95 | ((animateState.controlled ? animateState.play : play)
96 | ? animationNameRef.current.forward
97 | : animationNameRef.current.reverse) || ''
98 | }`,
99 | };
100 |
101 | return render ? render({ style }) : {children}
;
102 | }
103 |
--------------------------------------------------------------------------------
/src/constants/index.ts:
--------------------------------------------------------------------------------
1 | const DEFAULT_DURATION = 0.3;
2 | const DEFAULT_EASE_TYPE = 'linear';
3 | const DEFAULT_DIRECTION = 'normal';
4 | const DEFAULT_FILLMODE = 'none';
5 | const RUNNING = 'running';
6 | const PAUSED = 'paused';
7 | const ALL = 'all';
8 |
9 | export {
10 | DEFAULT_DURATION,
11 | DEFAULT_EASE_TYPE,
12 | DEFAULT_DIRECTION,
13 | DEFAULT_FILLMODE,
14 | RUNNING,
15 | PAUSED,
16 | ALL,
17 | };
18 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import Animate from './animate';
2 | import AnimateGroup from './animateGroup';
3 | import AnimateKeyframes from './animateKeyframes';
4 | import useAnimate from './useAnimate';
5 | import useAnimateKeyframes from './useAnimateKeyframes';
6 | import useAnimateGroup from './useAnimateGroup';
7 |
8 | export { Animate, AnimateGroup, AnimateKeyframes, useAnimate, useAnimateKeyframes, useAnimateGroup };
9 |
--------------------------------------------------------------------------------
/src/logic/__snapshots__/createStyle.test.ts.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`createStyle should create correct style keyframe object 1`] = `"@keyframes test { 0% { opacity: 0;} 25% { opacity: 0.25;} 50% { opacity: 0.5;} 75% { opacity: 0.75;} 100% { opacity: 1;}}"`;
4 |
5 | exports[`createStyle should create correct style 1`] = `"@keyframes test { 0% {opacity: 0;} 33.33% {opacity: 1;} 66.66% {opacity: 2;} 99.99% {opacity: 3;}}"`;
6 |
7 | exports[`createStyle should create correct style with object 1`] = `"@keyframes test { 0% {opacity: 0;} 25% {opacity: 1;} 75% {opacity: 2;} 100% {opacity: 3;}}"`;
8 |
--------------------------------------------------------------------------------
/src/logic/__snapshots__/createTag.test.ts.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`createTag should still return tag and index even insertRule thow error 1`] = `
4 | Object {
5 | "index": 2,
6 | "styleTag": Object {
7 | "setAttribute": [Function],
8 | "sheet": Object {
9 | "cssRules": Array [
10 | 1,
11 | 2,
12 | ],
13 | "insertRule": [Function],
14 | },
15 | },
16 | }
17 | `;
18 |
19 | exports[`createTag should the tag and total length of css rules 1`] = `
20 | Object {
21 | "index": 2,
22 | "styleTag": Object {
23 | "setAttribute": [Function],
24 | "sheet": Object {
25 | "cssRules": Array [
26 | 1,
27 | 2,
28 | ],
29 | "insertRule": [MockFunction] {
30 | "calls": Array [
31 | Array [
32 | ".test {}",
33 | 2,
34 | ],
35 | ],
36 | "results": Array [
37 | Object {
38 | "type": "return",
39 | "value": undefined,
40 | },
41 | ],
42 | },
43 | },
44 | },
45 | }
46 | `;
47 |
--------------------------------------------------------------------------------
/src/logic/createStyle.test.ts:
--------------------------------------------------------------------------------
1 | import createStyle from './createStyle';
2 |
3 | describe('createStyle', () => {
4 | it('should create correct style', () => {
5 | expect(
6 | createStyle({
7 | keyframes: ['opacity: 0;', 'opacity: 1;', 'opacity: 2;', 'opacity: 3;'],
8 | animationName: 'test',
9 | }),
10 | ).toMatchSnapshot();
11 | });
12 |
13 | it('should create correct style with object', () => {
14 | expect(
15 | createStyle({
16 | keyframes: [
17 | { 0: 'opacity: 0;' },
18 | { 25: 'opacity: 1;' },
19 | { 75: 'opacity: 2;' },
20 | { 100: 'opacity: 3;' },
21 | ],
22 | animationName: 'test',
23 | }),
24 | ).toMatchSnapshot();
25 | });
26 |
27 | it('should create correct style keyframe object', () => {
28 | expect(
29 | createStyle({
30 | keyframes: [
31 | { opacity: 0 },
32 | { opacity: 0.25 },
33 | { opacity: 0.5 },
34 | { opacity: 0.75 },
35 | { opacity: 1 },
36 | ],
37 | animationName: 'test',
38 | }),
39 | ).toMatchSnapshot();
40 | });
41 | });
42 |
--------------------------------------------------------------------------------
/src/logic/createStyle.ts:
--------------------------------------------------------------------------------
1 | import { Keyframes } from '../types';
2 | import camelCaseToDash from '../utils/camelCaseToDash';
3 |
4 | const generateKeyframes = (keyframes) => {
5 | const animationLength = keyframes.length;
6 | return keyframes.reduce((previous, keyframe, currentIndex) => {
7 | const keyframePercentage =
8 | parseFloat((100 / (animationLength - 1)).toFixed(2)) * currentIndex;
9 |
10 | if (typeof keyframe === 'string') {
11 | return `${previous} ${keyframePercentage}% {${keyframe}}`;
12 | }
13 | const keys = Object.keys(keyframe);
14 |
15 | if (keys.length && isNaN(+keys[0])) {
16 | const keyframeContent = keys.reduce(
17 | (acc, key) => `${acc} ${camelCaseToDash(key)}: ${keyframe[key]};`,
18 | '',
19 | );
20 | return `${previous} ${keyframePercentage}% {${keyframeContent}}`;
21 | }
22 | return `${previous} ${keys[0]}% {${keyframe[keys[0]]}}`;
23 | }, '');
24 | };
25 |
26 | export default function createStyle({
27 | keyframes,
28 | animationName,
29 | }: {
30 | keyframes: Keyframes;
31 | animationName: string;
32 | }): string {
33 | return `@keyframes ${animationName} {${generateKeyframes(keyframes)}}`;
34 | }
35 |
--------------------------------------------------------------------------------
/src/logic/createTag.test.ts:
--------------------------------------------------------------------------------
1 | import createTag from './createTag';
2 |
3 | jest.mock('./createStyle', () => ({
4 | default: () => '.test {}',
5 | }));
6 |
7 | const documentCreateOriginal = document.createElement;
8 | const appendChildOriginal = document.head.appendChild;
9 |
10 | describe('createTag', () => {
11 | beforeEach(() => {
12 | document.createElement = jest.fn();
13 | document.head.appendChild = jest.fn();
14 | });
15 |
16 | afterEach(() => {
17 | document.createElement = documentCreateOriginal;
18 | document.head.appendChild = appendChildOriginal;
19 | });
20 |
21 | it('should the tag and total length of css rules', () => {
22 | const insertRule = jest.fn();
23 | // @ts-ignore
24 | document.createElement.mockReturnValue({
25 | sheet: { cssRules: [1, 2], insertRule },
26 | setAttribute: () => {},
27 | });
28 | // @ts-ignore
29 | const output = createTag({ keyframes: {}, animationName: 'test' });
30 |
31 | expect(output).toMatchSnapshot();
32 | });
33 |
34 | it('should still return tag and index even insertRule thow error', () => {
35 | const insertRule = () => {
36 | throw new Error('failed');
37 | };
38 | // @ts-ignore
39 | document.createElement.mockReturnValue({
40 | sheet: { cssRules: [1, 2], insertRule },
41 | setAttribute: () => {},
42 | });
43 | // @ts-ignore
44 | const output = createTag({ keyframes: '', animationName: 'test' });
45 |
46 | expect(output).toMatchSnapshot();
47 | });
48 | });
49 |
--------------------------------------------------------------------------------
/src/logic/createTag.ts:
--------------------------------------------------------------------------------
1 | import createStyle from './createStyle';
2 | import { Keyframes } from '../types';
3 |
4 | export default function createTag({
5 | keyframes,
6 | animationName,
7 | }: {
8 | keyframes: Keyframes;
9 | animationName: string;
10 | }): {
11 | styleTag: HTMLStyleElement;
12 | index: number;
13 | } {
14 | let styleTag = document.querySelector('style[data-id=rsi]');
15 |
16 | if (!styleTag) {
17 | styleTag = document.createElement('style');
18 | styleTag.setAttribute('data-id', 'rsi');
19 | document.head.appendChild(styleTag);
20 | }
21 |
22 | const index = styleTag.sheet?.cssRules?.length ?? 0;
23 |
24 | try {
25 | styleTag.sheet?.insertRule(
26 | createStyle({
27 | keyframes,
28 | animationName,
29 | }),
30 | index,
31 | );
32 | } catch (e) {
33 | console.error('react simple animate, error found during insert style ', e); // eslint-disable-line no-console
34 | }
35 |
36 | return {
37 | styleTag,
38 | index,
39 | };
40 | }
41 |
--------------------------------------------------------------------------------
/src/logic/deleteRules.test.ts:
--------------------------------------------------------------------------------
1 | import deleteRules from './deleteRules';
2 |
3 | describe('deleteRules', () => {
4 | it('should do nothing when index not found', () => {
5 | const deleteRule = jest.fn();
6 | const data = {
7 | cssRules: {},
8 | deleteRule,
9 | } as any;
10 | deleteRules(data, 'whatever');
11 | expect(deleteRule).not.toBeCalled();
12 | });
13 |
14 | it('should delete the associated rule', () => {
15 | const deleteRule = jest.fn();
16 | const data = {
17 | deleteRule,
18 | cssRules: {
19 | name: {
20 | name: 'bill',
21 | },
22 | },
23 | } as any;
24 | deleteRules(data, 'bill');
25 | expect(deleteRule).toBeCalled();
26 | });
27 | });
28 |
--------------------------------------------------------------------------------
/src/logic/deleteRules.ts:
--------------------------------------------------------------------------------
1 | export default (
2 | sheet: CSSStyleSheet | null | undefined,
3 | deleteName: string,
4 | ): void => {
5 | if (!sheet) {
6 | return;
7 | }
8 | const index = Object.values(sheet.cssRules).findIndex(
9 | ({ name }: CSSKeyframesRule) => name === deleteName,
10 | );
11 | if (index >= 0) {
12 | sheet.deleteRule(index);
13 | }
14 | };
15 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | export interface AnimationType {
4 | play?: boolean;
5 | overlay?: number;
6 | duration?: number;
7 | delay?: number;
8 | easeType?: string;
9 | children?: any;
10 | register?: (data: any) => void;
11 | render?: (data: { style: React.CSSProperties | undefined }) => any;
12 | sequenceId?: string | number;
13 | sequenceIndex?: number;
14 | }
15 |
16 | export interface AnimationStateType {
17 | [key: string]: AnimationType;
18 | }
19 |
20 | export type Sequences = AnimationType[];
21 |
22 | export type HookSequences = {
23 | keyframes?: Keyframes;
24 | direction?: 'normal' | 'reverse' | 'alternate' | 'alternate-reverse';
25 | fillMode?: 'none' | 'forwards' | 'backwards' | 'both';
26 | iterationCount?: number;
27 | start?: React.CSSProperties;
28 | end?: React.CSSProperties;
29 | overlay?: number;
30 | duration?: number;
31 | delay?: number;
32 | easeType?: string;
33 | }[];
34 |
35 | export type Keyframes =
36 | | string[]
37 | | { [key: number]: string }[]
38 | | { [key: string]: string | number }[];
39 |
40 | export interface AnimationProps extends AnimationType {
41 | onComplete?: () => void;
42 | start?: React.CSSProperties;
43 | end?: React.CSSProperties;
44 | complete?: React.CSSProperties;
45 | animationStates?: AnimationStateType;
46 | }
47 |
48 | export interface AnimateKeyframesProps extends AnimationType {
49 | keyframes: Keyframes;
50 | direction?: 'normal' | 'reverse' | 'alternate' | 'alternate-reverse';
51 | fillMode?: 'none' | 'forwards' | 'backwards' | 'both';
52 | iterationCount?: string | number;
53 | animationStates?: AnimationStateType;
54 | pause?: boolean;
55 | }
56 |
--------------------------------------------------------------------------------
/src/useAnimate.test.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import useAnimate from './useAnimate';
3 | import { mount } from 'enzyme';
4 | import { act } from 'react-dom/test-utils';
5 |
6 | jest.useFakeTimers('modern');
7 |
8 | let UseAnimate;
9 |
10 | describe('useAnimate', () => {
11 | let componentStyle, onComplete;
12 |
13 | const TestHook = ({
14 | callback,
15 | }: {
16 | callback: () => { style: React.CSSProperties };
17 | }) => {
18 | const { style } = callback();
19 | componentStyle = style;
20 | return errors
;
21 | };
22 |
23 | const TestComponent = (callback) => {
24 | mount();
25 | };
26 |
27 | beforeEach(() => {
28 | onComplete = jest.fn();
29 |
30 | TestComponent(() => {
31 | UseAnimate = useAnimate({
32 | end: { opacity: 1 },
33 | onComplete: () => onComplete(),
34 | });
35 | return UseAnimate;
36 | });
37 |
38 | jest.resetAllMocks();
39 | });
40 |
41 | it('should toggle style correctly', () => {
42 | act(() => {
43 | expect(UseAnimate.play(true)).toBeUndefined();
44 | expect(componentStyle).toEqual({
45 | transition: 'all 0.3s linear 0s',
46 | });
47 | });
48 |
49 | expect(componentStyle).toEqual({
50 | opacity: 1,
51 | transition: 'all 0.3s linear 0s',
52 | });
53 | });
54 |
55 | it('should call onComplete only when isPlaying', () => {
56 | onComplete.mockImplementation(() => {
57 | act(() => {
58 | UseAnimate.play(false);
59 | });
60 | });
61 |
62 | act(() => {
63 | UseAnimate.play(true);
64 | });
65 |
66 | jest.runOnlyPendingTimers();
67 |
68 | expect(onComplete).toHaveBeenCalledTimes(1);
69 |
70 | jest.runOnlyPendingTimers();
71 |
72 | expect(onComplete).toHaveBeenCalledTimes(1);
73 | });
74 | });
75 |
--------------------------------------------------------------------------------
/src/useAnimate.ts:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import secToMs from './utils/secToMs';
3 | import { AnimationProps } from './types';
4 | import { ALL, DEFAULT_DURATION, DEFAULT_EASE_TYPE } from './constants';
5 |
6 | type UseAnimateProps = Pick<
7 | AnimationProps,
8 | | 'start'
9 | | 'end'
10 | | 'complete'
11 | | 'onComplete'
12 | | 'delay'
13 | | 'duration'
14 | | 'easeType'
15 | >;
16 | export default function useAnimate(props: UseAnimateProps): {
17 | isPlaying: boolean;
18 | style: React.CSSProperties;
19 | play: (isPlaying: boolean) => void;
20 | } {
21 | const {
22 | start,
23 | end,
24 | complete,
25 | onComplete,
26 | delay = 0,
27 | duration = DEFAULT_DURATION,
28 | easeType = DEFAULT_EASE_TYPE,
29 | } = props;
30 | const transition = React.useMemo(
31 | () => `${ALL} ${duration}s ${easeType} ${delay}s`,
32 | [duration, easeType, delay],
33 | );
34 | const [animate, setAnimate] = React.useState<{
35 | isPlaying: boolean;
36 | style: React.CSSProperties;
37 | }>({
38 | isPlaying: false,
39 | style: { ...start, transition },
40 | });
41 | const { isPlaying, style } = animate;
42 | const onCompleteTimeRef = React.useRef();
43 |
44 | React.useEffect(() => {
45 | if ((onComplete || complete) && isPlaying) {
46 | onCompleteTimeRef.current = setTimeout(() => {
47 | if (onComplete) {
48 | onComplete();
49 | }
50 |
51 | if (complete) {
52 | setAnimate((animate) => ({
53 | ...animate,
54 | style: complete,
55 | }));
56 | }
57 | }, secToMs(delay + duration));
58 | }
59 |
60 | return () =>
61 | onCompleteTimeRef.current && clearTimeout(onCompleteTimeRef.current);
62 | }, [animate, complete, delay, duration, isPlaying, onComplete]);
63 |
64 | return {
65 | isPlaying,
66 | style,
67 | play: React.useCallback(
68 | (isPlaying) => {
69 | setAnimate((animate) => ({
70 | ...animate,
71 | style: {
72 | ...(isPlaying ? end : start),
73 | transition,
74 | },
75 | isPlaying,
76 | }));
77 | },
78 | [end, start, transition],
79 | ),
80 | };
81 | }
82 |
--------------------------------------------------------------------------------
/src/useAnimateGroup.test.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { mount } from 'enzyme';
3 | import useAnimateGroup from './useAnimateGroup';
4 | import { act } from 'react-dom/test-utils';
5 |
6 | jest.mock('./utils/createRandomName', () => ({
7 | default: () => 'test',
8 | }));
9 |
10 | let UseAnimateGroup;
11 |
12 | describe('useAnimateGroup', () => {
13 | let componentStyle;
14 |
15 | const TestHook = ({
16 | callback,
17 | }: {
18 | callback: () => { styles: React.CSSProperties };
19 | }) => {
20 | const { styles } = callback();
21 | componentStyle = styles;
22 | return errors
;
23 | };
24 |
25 | const TestComponent = (callback) => {
26 | mount();
27 | };
28 |
29 | beforeEach(() => {
30 | jest.resetAllMocks();
31 | });
32 |
33 | it('should generate correct styles', () => {
34 | TestComponent(() => {
35 | UseAnimateGroup = useAnimateGroup({
36 | sequences: [
37 | { start: { opacity: 0 }, end: { opacity: 1 } },
38 | { keyframes: ['opacity: 0', 'opacity: 1'] },
39 | { start: { opacity: 0 }, end: { opacity: 1 } },
40 | { keyframes: ['opacity: 0', 'opacity: 1'] },
41 | ],
42 | });
43 | return UseAnimateGroup;
44 | });
45 | expect(componentStyle).toEqual([
46 | { opacity: 0 },
47 | undefined,
48 | { opacity: 0 },
49 | undefined,
50 | ]);
51 |
52 | const forwardStyles = [
53 | { opacity: 1, transition: 'all 0.3s linear 0s' },
54 | { animation: '0.3s linear 0.3s 1 normal none running test' },
55 | { opacity: 1, transition: 'all 0.3s linear 0.6s' },
56 | {
57 | animation: '0.3s linear 0.8999999999999999s 1 normal none running test',
58 | },
59 | ];
60 | const reverseStyles = [
61 | { opacity: 0, transition: 'all 0.3s linear 0.8999999999999999s' },
62 | { animation: '0.3s linear 0.6s 1 normal none running test' },
63 | { opacity: 0, transition: 'all 0.3s linear 0.3s' },
64 | {
65 | animation: '0.3s linear 0s 1 normal none running test',
66 | },
67 | ];
68 |
69 | act(() => {
70 | UseAnimateGroup.play(true);
71 | });
72 | expect(componentStyle).toEqual(forwardStyles);
73 |
74 | act(() => {
75 | UseAnimateGroup.play(false);
76 | });
77 | expect(componentStyle).toEqual(reverseStyles);
78 | });
79 |
80 | it('should update isPlaying after calling play', () => {
81 | TestComponent(() => {
82 | UseAnimateGroup = useAnimateGroup({
83 | sequences: [],
84 | });
85 | return UseAnimateGroup;
86 | });
87 |
88 | act(() => {
89 | UseAnimateGroup.play(true);
90 | });
91 | expect(UseAnimateGroup.isPlaying).toEqual(true);
92 |
93 | act(() => {
94 | UseAnimateGroup.play(false);
95 | });
96 | expect(UseAnimateGroup.isPlaying).toEqual(false);
97 |
98 | act(() => {
99 | UseAnimateGroup.play(true);
100 | });
101 | expect(UseAnimateGroup.isPlaying).toEqual(true);
102 | });
103 | });
104 |
--------------------------------------------------------------------------------
/src/useAnimateGroup.ts:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import createRandomName from './utils/createRandomName';
3 | import createArrayWithNumbers from './utils/createArrayWithNumbers';
4 | import calculateTotalDuration from './utils/calculateTotalDuration';
5 | import createTag from './logic/createTag';
6 | import deleteRules from './logic/deleteRules';
7 | import { HookSequences } from './types';
8 | import {
9 | ALL,
10 | DEFAULT_DIRECTION,
11 | DEFAULT_DURATION,
12 | DEFAULT_EASE_TYPE,
13 | DEFAULT_FILLMODE,
14 | RUNNING,
15 | } from './constants';
16 |
17 | interface Props {
18 | sequences: HookSequences;
19 | }
20 |
21 | export default function useAnimateGroup(props: Props): {
22 | styles: (React.CSSProperties | undefined)[];
23 | play: (boolean) => void;
24 | isPlaying: boolean;
25 | } {
26 | const { sequences = [] } = props;
27 | const defaultArray = createArrayWithNumbers(sequences.length).map(
28 | (_, index) => props.sequences[index].start,
29 | ) as React.CSSProperties[];
30 | const [styles, setStyles] = React.useState(defaultArray);
31 | const [isPlaying, setPlaying] = React.useState(false);
32 | const animationNamesRef = React.useRef<
33 | { forward: string; reverse: string }[]
34 | >([]);
35 | const styleTagRef = React.useRef<
36 | { forward?: HTMLStyleElement; reverse?: HTMLStyleElement }[]
37 | >([]);
38 |
39 | React.useEffect(() => {
40 | sequences.forEach(({ keyframes }, i) => {
41 | if (!Array.isArray(keyframes)) {
42 | return;
43 | }
44 |
45 | if (!animationNamesRef.current[i]) {
46 | animationNamesRef.current[i] = {} as any;
47 | styleTagRef.current[i] = {};
48 | }
49 |
50 | animationNamesRef.current[i].forward = createRandomName();
51 | let result = createTag({
52 | animationName: animationNamesRef.current[i].forward,
53 | keyframes,
54 | });
55 | styleTagRef.current[i].forward = result.styleTag;
56 |
57 | animationNamesRef.current[i].reverse = createRandomName();
58 | result = createTag({
59 | animationName: animationNamesRef.current[i].reverse,
60 | keyframes: keyframes.reverse(),
61 | });
62 | styleTagRef.current[i].reverse = result.styleTag;
63 | });
64 |
65 | const styleTags = styleTagRef.current;
66 | const animationNames = animationNamesRef.current;
67 |
68 | return () =>
69 | Object.values(animationNames).forEach(({ forward, reverse }, i) => {
70 | deleteRules(styleTags[i].forward?.sheet, forward);
71 | deleteRules(styleTags[i].reverse?.sheet, reverse);
72 | });
73 | // eslint-disable-next-line react-hooks/exhaustive-deps
74 | }, []);
75 |
76 | const play = React.useCallback((isPlay: boolean) => {
77 | let totalDuration = 0;
78 | const animationRefWithOrder = isPlay
79 | ? animationNamesRef.current
80 | : [...animationNamesRef.current].reverse();
81 | const styles = (isPlay ? sequences : [...sequences].reverse()).map(
82 | (current, currentIndex): React.CSSProperties => {
83 | const {
84 | duration = DEFAULT_DURATION,
85 | delay = 0,
86 | overlay,
87 | keyframes,
88 | iterationCount = 1,
89 | easeType = DEFAULT_EASE_TYPE,
90 | direction = DEFAULT_DIRECTION,
91 | fillMode = DEFAULT_FILLMODE,
92 | end = {},
93 | start = {},
94 | } = current;
95 | const delayDuration = currentIndex === 0 ? delay : totalDuration;
96 | const transition = `${ALL} ${duration}s ${easeType} ${delayDuration}s`;
97 | totalDuration =
98 | calculateTotalDuration({ duration, delay, overlay }) + totalDuration;
99 |
100 | return keyframes
101 | ? {
102 | animation: `${duration}s ${easeType} ${delayDuration}s ${iterationCount} ${direction} ${fillMode} ${RUNNING} ${
103 | isPlay
104 | ? animationRefWithOrder[currentIndex].forward
105 | : animationRefWithOrder[currentIndex].reverse
106 | }`,
107 | }
108 | : {
109 | ...(isPlay ? end : start),
110 | transition,
111 | };
112 | },
113 | );
114 |
115 | setStyles(isPlay ? styles : [...styles].reverse());
116 | setPlaying(isPlay);
117 | }, []);
118 |
119 | return { styles, play, isPlaying };
120 | }
121 |
--------------------------------------------------------------------------------
/src/useAnimateKeyframes.test.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import useAnimateKeyframes from './useAnimateKeyframes';
3 | import { mount } from 'enzyme';
4 | import { act } from 'react-dom/test-utils';
5 |
6 | jest.mock('./utils/createRandomName', () => ({
7 | default: () => 'test',
8 | }));
9 |
10 | let UseAnimate;
11 |
12 | describe('useAnimateKeyframes', () => {
13 | let componentStyle;
14 |
15 | const TestHook = ({ callback }: any) => {
16 | const { style } = callback();
17 | componentStyle = style;
18 | return errors
;
19 | };
20 |
21 | const TestComponent = (callback) => {
22 | mount();
23 | };
24 |
25 | beforeEach(() => {
26 | TestComponent(() => {
27 | UseAnimate = useAnimateKeyframes({
28 | keyframes: ['opacity: 0', 'opacity: 1'],
29 | });
30 | return UseAnimate;
31 | });
32 |
33 | jest.resetAllMocks();
34 | });
35 |
36 | it('should toggle style correctly', () => {
37 | act(() => {
38 | expect(UseAnimate.play(true)).toBeUndefined();
39 | expect(componentStyle).toEqual({
40 | animation: '0.3s linear 0s 1 normal none running ',
41 | });
42 | });
43 |
44 | expect(componentStyle).toEqual({
45 | animation: '0.3s linear 0s 1 normal none running test',
46 | });
47 | });
48 | });
49 |
--------------------------------------------------------------------------------
/src/useAnimateKeyframes.ts:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import createRandomName from './utils/createRandomName';
3 | import createTag from './logic/createTag';
4 | import { AnimateContext } from './animateGroup';
5 | import deleteRules from './logic/deleteRules';
6 | import { AnimateKeyframesProps } from './types';
7 | import getPlayState from './utils/getPauseState';
8 | import {
9 | DEFAULT_DIRECTION,
10 | DEFAULT_DURATION,
11 | DEFAULT_EASE_TYPE,
12 | DEFAULT_FILLMODE,
13 | } from './constants';
14 |
15 | type UseAnimationKeyframesProps = Pick<
16 | AnimateKeyframesProps,
17 | | 'duration'
18 | | 'delay'
19 | | 'easeType'
20 | | 'direction'
21 | | 'fillMode'
22 | | 'iterationCount'
23 | | 'keyframes'
24 | >;
25 | export default function useAnimateKeyframes(
26 | props: UseAnimationKeyframesProps,
27 | ): {
28 | style: React.CSSProperties;
29 | play: (isPlaying: boolean) => void;
30 | pause: (isPaused: boolean) => void;
31 | isPlaying: boolean;
32 | } {
33 | const {
34 | duration = DEFAULT_DURATION,
35 | delay = 0,
36 | easeType = DEFAULT_EASE_TYPE,
37 | direction = DEFAULT_DIRECTION,
38 | fillMode = DEFAULT_FILLMODE,
39 | iterationCount = 1,
40 | keyframes,
41 | } = props;
42 | const animationNameRef = React.useRef({
43 | forward: '',
44 | reverse: '',
45 | });
46 | const styleTagRef = React.useRef<
47 | Record<'forward' | 'reverse', HTMLStyleElement | null>
48 | >({
49 | forward: null,
50 | reverse: null,
51 | });
52 | const { register } = React.useContext(AnimateContext);
53 | const [isPlaying, setIsPlaying] = React.useState(null);
54 | const [isPaused, setIsPaused] = React.useState(false);
55 |
56 | React.useEffect(() => {
57 | const styleTag = styleTagRef.current;
58 | const animationName = animationNameRef.current;
59 |
60 | animationNameRef.current.forward = createRandomName();
61 | let result = createTag({
62 | animationName: animationNameRef.current.forward,
63 | keyframes,
64 | });
65 | styleTagRef.current.forward = result.styleTag;
66 |
67 | animationNameRef.current.reverse = createRandomName();
68 | result = createTag({
69 | animationName: animationNameRef.current.reverse,
70 | keyframes: keyframes.reverse(),
71 | });
72 | styleTagRef.current.reverse = result.styleTag;
73 |
74 | register(props);
75 |
76 | return () => {
77 | deleteRules(styleTag.forward?.sheet, animationName.forward);
78 | deleteRules(styleTag.reverse?.sheet, animationName.reverse);
79 | };
80 | // eslint-disable-next-line react-hooks/exhaustive-deps
81 | }, []);
82 |
83 | const style = {
84 | animation: `${duration}s ${easeType} ${delay}s ${iterationCount} ${direction} ${fillMode} ${getPlayState(
85 | isPaused,
86 | )} ${
87 | isPlaying === null
88 | ? ''
89 | : isPlaying
90 | ? animationNameRef.current.forward
91 | : animationNameRef.current.reverse
92 | }`,
93 | };
94 |
95 | return {
96 | style,
97 | play: setIsPlaying,
98 | pause: setIsPaused,
99 | isPlaying: !!isPlaying,
100 | };
101 | }
102 |
--------------------------------------------------------------------------------
/src/utils/calculateTotalDuration.test.ts:
--------------------------------------------------------------------------------
1 | import calculateTotalDuration from '../../src/utils/calculateTotalDuration';
2 |
3 | describe('calculateTotalDuration', () => {
4 | it('should return correctly seconds', () => {
5 | expect(calculateTotalDuration({ duration: 1, delay: 1 })).toEqual(2);
6 | });
7 |
8 | it('should return correctly seconds when play on reverse', () => {
9 | expect(
10 | calculateTotalDuration({
11 | duration: 1,
12 | delay: 1,
13 | }),
14 | ).toEqual(2);
15 | });
16 |
17 | it('should return correctly when those default duration and delay are not supplied', () => {
18 | expect(calculateTotalDuration({})).toEqual(0.3);
19 | });
20 | });
21 |
--------------------------------------------------------------------------------
/src/utils/calculateTotalDuration.ts:
--------------------------------------------------------------------------------
1 | import { DEFAULT_DURATION } from '../constants';
2 |
3 | export default ({
4 | duration = DEFAULT_DURATION,
5 | delay = 0,
6 | overlay = 0,
7 | }: {
8 | duration?: number;
9 | delay?: number;
10 | overlay?: number;
11 | }): number => duration + delay - overlay || 0;
12 |
--------------------------------------------------------------------------------
/src/utils/camelCaseToDash.test.ts:
--------------------------------------------------------------------------------
1 | import camelCaseToDash from './camelCaseToDash';
2 |
3 | describe('camelCaseToDash', () => {
4 | it('should transfer camel case string to dash', () => {
5 | expect(camelCaseToDash('backgroundColor')).toEqual('background-color');
6 | });
7 |
8 | it('should return empty when given null or undefined or empty string', () => {
9 | expect(camelCaseToDash('')).toEqual('');
10 | expect(camelCaseToDash()).toEqual('');
11 | });
12 | });
13 |
--------------------------------------------------------------------------------
/src/utils/camelCaseToDash.ts:
--------------------------------------------------------------------------------
1 | export default (camelCase?: string): string =>
2 | camelCase ? camelCase.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`) : '';
3 |
--------------------------------------------------------------------------------
/src/utils/createArrayWithNumbers.ts:
--------------------------------------------------------------------------------
1 | export default function createArrayWithNumbers(length: number): null[] {
2 | return Array.from({ length }, (): null => null);
3 | }
4 |
--------------------------------------------------------------------------------
/src/utils/createRandomName.ts:
--------------------------------------------------------------------------------
1 | export default (): string => `RSI-${Math.random()
2 | .toString(36)
3 | .substr(2, 9)}`
4 |
--------------------------------------------------------------------------------
/src/utils/getPauseState.test.ts:
--------------------------------------------------------------------------------
1 | import getPlayState from './getPauseState';
2 |
3 | describe('getPlayState', () => {
4 | it('should return correct value', () => {
5 | expect(getPlayState(true)).toEqual('paused');
6 | expect(getPlayState(false)).toEqual('running');
7 | });
8 | });
9 |
--------------------------------------------------------------------------------
/src/utils/getPauseState.ts:
--------------------------------------------------------------------------------
1 | import { PAUSED, RUNNING } from '../constants';
2 |
3 | export default (pause: boolean): string => (pause ? PAUSED : RUNNING);
4 |
--------------------------------------------------------------------------------
/src/utils/getSequenceId.test.ts:
--------------------------------------------------------------------------------
1 | import getSequenceId from './getSequenceId';
2 |
3 | describe('getSequenceId', () => {
4 | it('should return 0 when both undefined', () => {
5 | expect(getSequenceId(undefined, undefined)).toEqual(0);
6 | });
7 |
8 | it('should return sequence index when defined', () => {
9 | expect(getSequenceId(1, undefined)).toEqual(1);
10 | });
11 |
12 | it('should return sequence id when defined', () => {
13 | expect(getSequenceId(undefined, '2')).toEqual('2');
14 | });
15 |
16 | it('should return default when nothing defined', () => {
17 | expect(getSequenceId(undefined, undefined, 2)).toEqual(2);
18 | });
19 | });
20 |
--------------------------------------------------------------------------------
/src/utils/getSequenceId.ts:
--------------------------------------------------------------------------------
1 | import isUndefined from './isUndefined';
2 |
3 | export default function getSequenceId(
4 | sequenceIndex?: number,
5 | sequenceId?: string | number,
6 | defaultValue?: string | number,
7 | ): number | string {
8 | if (isUndefined(sequenceId) && isUndefined(sequenceIndex)) {
9 | return defaultValue || 0;
10 | }
11 |
12 | if (sequenceIndex && sequenceIndex >= 0) {
13 | return sequenceIndex;
14 | }
15 |
16 | if (sequenceId) {
17 | return sequenceId;
18 | }
19 |
20 | return 0;
21 | }
22 |
--------------------------------------------------------------------------------
/src/utils/isUndefined.test.ts:
--------------------------------------------------------------------------------
1 | import isUndefined from './isUndefined';
2 |
3 | describe('isUndefined', () => {
4 | it('should return true when it is an undefined value', () => {
5 | expect(isUndefined(undefined)).toBeTruthy();
6 | });
7 |
8 | it('should return false when it is not an undefined value', () => {
9 | expect(isUndefined(null)).toBeFalsy();
10 | expect(isUndefined('')).toBeFalsy();
11 | expect(isUndefined('undefined')).toBeFalsy();
12 | expect(isUndefined(0)).toBeFalsy();
13 | expect(isUndefined([])).toBeFalsy();
14 | expect(isUndefined({})).toBeFalsy();
15 | });
16 | });
17 |
--------------------------------------------------------------------------------
/src/utils/isUndefined.ts:
--------------------------------------------------------------------------------
1 | export default (val: unknown): val is undefined => val === undefined;
2 |
--------------------------------------------------------------------------------
/src/utils/secToMs.test.ts:
--------------------------------------------------------------------------------
1 | import secToMs from './secToMs';
2 |
3 | describe('secToMs', () => {
4 | it('should convert to', () => {
5 | expect(secToMs(1)).toEqual(1000);
6 | });
7 | });
8 |
--------------------------------------------------------------------------------
/src/utils/secToMs.ts:
--------------------------------------------------------------------------------
1 | export default (ms: number): number => (ms || 0) * 1000;
2 |
--------------------------------------------------------------------------------
/tsconfig.jest.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "jsx": "react",
5 | "module": "commonjs"
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "sourceMap": true,
4 | "module": "es2015",
5 | "target": "es2018",
6 | "moduleResolution": "node",
7 | "outDir": "./dist",
8 | "declaration": true,
9 | "strictNullChecks": true,
10 | "jsx": "react"
11 | },
12 | "include": ["src"],
13 | "exclude": ["node_modules", "test", "examples"]
14 | }
15 |
--------------------------------------------------------------------------------
/website/README.md:
--------------------------------------------------------------------------------
1 | Website Repo:
2 |
3 | https://github.com/bluebill1049/react-simple-animate-web-site
4 |
--------------------------------------------------------------------------------