├── .eslintrc.js ├── .gitignore ├── .storybook ├── addons.js └── config.js ├── README.md ├── gifs ├── array_update.gif ├── basic_enter_exit.gif └── group_update.gif ├── package.json ├── public ├── favicon.ico ├── index.html └── manifest.json ├── src ├── App.js ├── components │ ├── Item │ │ └── index.js │ └── List │ │ └── index.js ├── examples │ ├── ArrayUpdateExample │ │ ├── AddArrayAnimationsHOC.js │ │ ├── animations.js │ │ └── index.js │ ├── BasicEnterExitExample │ │ ├── AddAnimationsHOC.js │ │ ├── animations.js │ │ └── index.js │ └── GroupItemsExample │ │ ├── GroupItemsHOC.js │ │ ├── animations.js │ │ └── index.js ├── index.css ├── index.js └── stories │ ├── ArrayUpdateExampleWrapper.js │ ├── BasicEnterExitExampleWrapper.js │ ├── GroupItemsExampleWrapper.js │ ├── generateItems.js │ └── index.js └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "extends": "standard", 3 | "plugins": [ 4 | "react" 5 | ] 6 | }; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /.storybook/addons.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aholachek/react-animations-from-scratch/75192893662c2a180b6492b1e3765251a7e33c1e/.storybook/addons.js -------------------------------------------------------------------------------- /.storybook/config.js: -------------------------------------------------------------------------------- 1 | import { configure } from '@storybook/react'; 2 | 3 | function loadStories() { 4 | require('../src/stories'); 5 | } 6 | 7 | configure(loadStories, module); 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [This blog post explains the techniques in more depth](https://medium.com/about-codecademy/building-animations-in-react-from-scratch-c66a582c9b65) 2 | 3 | # Building animations in React from scratch 4 | 5 | ## 1. Basic sequenced enter & exit 6 | ![enter and exit animation](./gifs/basic_enter_exit.gif) 7 | 8 | - [Live example](http://alex.holachek.com/react-animations-from-scratch/?selectedKind=Animation%20Examples&selectedStory=1.%20The%20basic%20technique&full=0&down=1&left=1&panelRight=0) 9 | - [Code](./src/examples/BasicEnterExitExample) 10 | 11 | 12 | ## 2. Grouping items 13 | ![grouping items transition](./gifs/group_update.gif) 14 | 15 | - [Live example](http://alex.holachek.com/react-animations-from-scratch/?selectedKind=Animation%20Examples&selectedStory=2.%20Object%20persistence&full=0&down=1&left=1&panelRight=0) 16 | - [Code](./src/examples/GroupItemsExample) 17 | 18 | 19 | ## 3. Enter/update/exit 20 | ![enter/update/exit animation](./gifs/array_update.gif) 21 | 22 | - [Live example](http://alex.holachek.com/react-animations-from-scratch/?selectedKind=Animation%20Examples&selectedStory=3.%20Enter%2C%20update%20and%20delete%20transitions&full=0&down=1&left=1&panelRight=0) 23 | - [Code](./src/examples/ArrayUpdateExample) 24 | -------------------------------------------------------------------------------- /gifs/array_update.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aholachek/react-animations-from-scratch/75192893662c2a180b6492b1e3765251a7e33c1e/gifs/array_update.gif -------------------------------------------------------------------------------- /gifs/basic_enter_exit.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aholachek/react-animations-from-scratch/75192893662c2a180b6492b1e3765251a7e33c1e/gifs/basic_enter_exit.gif -------------------------------------------------------------------------------- /gifs/group_update.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aholachek/react-animations-from-scratch/75192893662c2a180b6492b1e3765251a7e33c1e/gifs/group_update.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hoc_demo", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "animejs": "^2.2.0", 7 | "deep-equal": "^1.0.1", 8 | "lodash": "^4.17.4", 9 | "react": "^16.0.0", 10 | "react-dom": "^16.0.0", 11 | "react-scripts": "1.0.14" 12 | }, 13 | "scripts": { 14 | "start": "react-scripts start", 15 | "build": "react-scripts build", 16 | "test": "react-scripts test --env=jsdom", 17 | "eject": "react-scripts eject", 18 | "storybook": "start-storybook -p 9009 -s public", 19 | "build-storybook": "build-storybook -s public", 20 | "deploy-storybook": "storybook-to-ghpages" 21 | }, 22 | "devDependencies": { 23 | "@storybook/addon-actions": "^3.2.13", 24 | "@storybook/addon-links": "^3.2.13", 25 | "@storybook/react": "^3.2.13", 26 | "@storybook/storybook-deployer": "^2.0.0", 27 | "eslint": "^4.10.0", 28 | "eslint-config-standard": "^10.2.1", 29 | "eslint-plugin-import": "^2.8.0", 30 | "eslint-plugin-node": "^5.2.1", 31 | "eslint-plugin-promise": "^3.6.0", 32 | "eslint-plugin-react": "^7.4.0", 33 | "eslint-plugin-standard": "^3.0.1" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aholachek/react-animations-from-scratch/75192893662c2a180b6492b1e3765251a7e33c1e/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 23 | React App 24 | 25 | 26 | 29 |
30 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react" 2 | import AddAnimations from "./HOC/AddAnimations" 3 | import AddArrayAnimations from "./HOC/AddArrayAnimations" 4 | import List from "./List" 5 | import { animateIn, animateOut } from "./List/animations" 6 | import { animateItemsIn, animateItemsOut } from "./Item/animations" 7 | import { compose } from "lodash/fp" 8 | 9 | const ListWithAnimation = compose( 10 | // AddArrayAnimations(animateItemsIn, animateItemsOut), 11 | AddAnimations(animateIn, animateOut) 12 | )(List) 13 | 14 | class App extends Component { 15 | state = { items: 0 } 16 | render() { 17 | return ( 18 |
19 |
20 |
21 | {this.state.items ? ( 22 | 28 | ) : ( 29 | 35 | )} 36 |
37 | {!!this.state.items && ( 38 |
39 | 48 | 57 |
58 | )} 59 |
60 | 64 |
65 | ) 66 | } 67 | } 68 | 69 | export default App 70 | -------------------------------------------------------------------------------- /src/components/Item/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | const Item = ({ item, defaultInvisible }) => { 5 | let className = `item ${defaultInvisible ? 'item__invisible' : ''}` 6 | if (item.shape) className = `${className} item__${item.shape}` 7 | if (item.color) className = `${className} item__${item.color}` 8 | return ( 9 |
  • 15 | {item.letter ? item.letter : item.id} 16 |
  • 17 | ) 18 | } 19 | 20 | Item.propTypes = { 21 | item: PropTypes.object, 22 | defaultInvisible: PropTypes.bool 23 | } 24 | 25 | export default Item 26 | -------------------------------------------------------------------------------- /src/components/List/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | import Item from './../Item' 4 | 5 | // this cannot be a functional component 6 | // as we will need to access its ref from the HOC wrapper 7 | export default class List extends React.Component { 8 | static propTypes = { 9 | items: PropTypes.oneOfType([PropTypes.array, PropTypes.object]).isRequired, 10 | defaultInvisibleItems: PropTypes.bool, 11 | backgroundColor: PropTypes.bool, 12 | // to show that animations can proceed even as other parts of the component 13 | // are getting updated 14 | unrelatedProp: PropTypes.string 15 | } 16 | render() { 17 | const { 18 | items, 19 | defaultInvisibleItems, 20 | backgroundColor, 21 | unrelatedProp 22 | } = this.props 23 | 24 | const unrelatedSection = unrelatedProp ? ( 25 |
    26 | Unrelated updating props will not interfere with the animation: 27 |
    28 | {unrelatedProp} 29 |
    30 |
    31 | ) : null 32 | 33 | if (Array.isArray(items)) { 34 | return ( 35 |
    36 | {unrelatedSection} 37 | 46 |
    47 | ) 48 | } else { 49 | return ( 50 |
    51 | {Object.values(items).map(itemGroup => ( 52 | 56 | ))} 57 |
    58 | ) 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/examples/ArrayUpdateExample/AddArrayAnimationsHOC.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import ReactDOM from 'react-dom' 3 | 4 | // function used by componentWillReceiveProps and shouldComponentUpdate 5 | // to figure out whether there are entering items and/or exiting items 6 | function getComparisonData(currentProps, nextProps) { 7 | const currentIds = currentProps.items.map(item => item.id) 8 | const nextIds = nextProps.items.map(item => item.id) 9 | const removedIds = currentIds.filter(id => !nextIds.includes(id)) 10 | const addedIds = nextIds.filter(id => !currentIds.includes(id)) 11 | const persistentIds = nextIds.filter(id => currentIds.includes(id)) 12 | return { 13 | removedIds, 14 | addedIds, 15 | persistentIds 16 | } 17 | } 18 | 19 | export default function addArrayAnimations( 20 | animateItemsIn, 21 | animateItemsOut, 22 | animatePersistentItems 23 | ) { 24 | return function wrapComponent(WrappedComponent) { 25 | return class AnimationHOC extends Component { 26 | state = { 27 | animatingOutItems: undefined 28 | } 29 | 30 | componentDidMount() { 31 | animateItemsIn(this.child, this.props.items.map(item => item.id), true) 32 | } 33 | 34 | componentWillReceiveProps(nextProps) { 35 | const { removedIds } = getComparisonData(this.props, nextProps) 36 | removedIds.length && 37 | this.setState({ animatingOutItems: this.props.items }) 38 | } 39 | 40 | shouldComponentUpdate(nextProps, nextState) { 41 | const { removedIds, addedIds, persistentIds } = getComparisonData( 42 | this.props, 43 | nextProps 44 | ) 45 | 46 | if (removedIds.length || addedIds.length) { 47 | // preload this function with previous item positions 48 | const animatePersistentPositions = animatePersistentItems( 49 | this.child, 50 | persistentIds 51 | ) 52 | 53 | animateItemsOut(this.child, removedIds).then(() => { 54 | // this function will be called by componentDidUpdate as soon as we set 55 | // animatingOutItems state to undefined 56 | this._updateAnimation = () => { 57 | animatePersistentPositions().then(() => 58 | animateItemsIn(this.child, addedIds) 59 | ) 60 | } 61 | this.setState({ animatingOutItems: undefined }) 62 | }) 63 | return false 64 | } else { 65 | return true 66 | } 67 | } 68 | 69 | componentDidUpdate(prevProps, prevState) { 70 | if (prevState.animatingOutItems && !this.state.animatingOutItems) { 71 | this._updateAnimation() 72 | delete this._updateAnimation 73 | } 74 | } 75 | 76 | render() { 77 | const getRef = component => { 78 | return component && (this.child = ReactDOM.findDOMNode(component)) 79 | } 80 | const { items, ...passThroughProps } = this.props 81 | 82 | return ( 83 | 89 | ) 90 | } 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/examples/ArrayUpdateExample/animations.js: -------------------------------------------------------------------------------- 1 | import anime from 'animejs' 2 | 3 | const duration = 1000 4 | const delay = (el, i) => i * 100 5 | 6 | // store reference to current animation 7 | let currentAnimation 8 | // in case updates are coming in while another animation is still in progress, 9 | // just fast forward the curent animation to finish it up 10 | const fastForwardCurrentAnimation = () => { 11 | if (currentAnimation) currentAnimation.seek(currentAnimation.duration) 12 | } 13 | 14 | const getItems = (container, ids) => { 15 | return [...container.querySelectorAll('.item')].filter(c => { 16 | return ids.includes(parseInt(c.dataset.id)) 17 | }) 18 | } 19 | export const animateItemsOut = (ListContainer, removedIds) => { 20 | fastForwardCurrentAnimation() 21 | const removedItems = getItems(ListContainer, removedIds) 22 | if (!removedItems.length) return new Promise(resolve => resolve()) 23 | 24 | currentAnimation = anime({ 25 | targets: removedItems, 26 | opacity: 0, 27 | scale: 0, 28 | duration: duration/2, 29 | delay 30 | }) 31 | return currentAnimation.finished 32 | } 33 | 34 | export const animatePersistentItems = (ListContainer, persistentIds) => { 35 | const persistentItems = getItems(ListContainer, persistentIds) 36 | const oldPositionDict = persistentItems.reduce((acc, Item) => { 37 | acc[Item.dataset.id] = Item.getBoundingClientRect() 38 | return acc 39 | }, {}) 40 | return function () { 41 | fastForwardCurrentAnimation() 42 | const persistentItems = getItems(ListContainer, persistentIds) 43 | if (!persistentItems.length) return new Promise(resolve => resolve()) 44 | 45 | const transformPositionDict = {} 46 | const targets = persistentItems.filter(item => { 47 | const oldPosition = oldPositionDict[item.dataset.id] 48 | // animations might be cycling through rapidly, so just ignore this node 49 | if (!oldPosition) return false 50 | const newPosition = item.getBoundingClientRect() 51 | const translateX = oldPosition.left - newPosition.left 52 | const translateY = oldPosition.top - newPosition.top 53 | item.style.transform = `translate(${translateX}px, ${translateY}px)` 54 | transformPositionDict[item.dataset.id] = { 55 | translateX: [translateX, 0], 56 | translateY: [translateY, 0] 57 | } 58 | return true 59 | }) 60 | 61 | currentAnimation = anime({ 62 | targets, 63 | translateX: Item => transformPositionDict[Item.dataset.id].translateX, 64 | translateY: Item => transformPositionDict[Item.dataset.id].translateY, 65 | opacity: 1, 66 | duration, 67 | delay 68 | }) 69 | return currentAnimation.finished 70 | } 71 | } 72 | 73 | export const animateItemsIn = (ListContainer, newIds, skipAnimation) => { 74 | fastForwardCurrentAnimation() 75 | const newItems = getItems(ListContainer, newIds) 76 | 77 | currentAnimation = anime({ 78 | targets: newItems, 79 | opacity: [0, 1], 80 | scale: [0, 1], 81 | duration: skipAnimation ? 0 : duration / 2, 82 | delay 83 | }) 84 | return currentAnimation.finished 85 | } 86 | -------------------------------------------------------------------------------- /src/examples/ArrayUpdateExample/index.js: -------------------------------------------------------------------------------- 1 | import List from './../../components/List' 2 | import AddArrayAnimationsHOC from './AddArrayAnimationsHOC' 3 | import { 4 | animateItemsIn, 5 | animateItemsOut, 6 | animatePersistentItems 7 | } from './animations' 8 | 9 | export default AddArrayAnimationsHOC( 10 | animateItemsIn, 11 | animateItemsOut, 12 | animatePersistentItems 13 | )(List) 14 | -------------------------------------------------------------------------------- /src/examples/BasicEnterExitExample/AddAnimationsHOC.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import ReactDOM from 'react-dom' 3 | 4 | export default function addAnimation(animateIn, animateOut) { 5 | return function wrapComponent(WrappedComponent) { 6 | return class AnimationHOC extends Component { 7 | 8 | state = { animatingOut: false } 9 | 10 | componentDidMount() { 11 | if (this.props.isVisible) animateIn(this.child) 12 | } 13 | 14 | componentWillReceiveProps(nextProps) { 15 | if (this.props.isVisible && !nextProps.isVisible) { 16 | this.setState({ animatingOut: true }) 17 | } 18 | } 19 | 20 | shouldComponentUpdate(nextProps) { 21 | if (this.props.isVisible && !nextProps.isVisible) { 22 | animateOut(this.child, () => { 23 | this.setState({ animatingOut: false }) 24 | }) 25 | return false 26 | } 27 | return true 28 | } 29 | 30 | componentDidUpdate(prevProps) { 31 | if (!prevProps.isVisible && this.props.isVisible) { 32 | animateIn(this.child) 33 | } 34 | } 35 | 36 | render() { 37 | const { isVisible, ...rest } = this.props 38 | const getRef = component => { 39 | return component && (this.child = ReactDOM.findDOMNode(component)) 40 | } 41 | return ( 42 | (!!isVisible || this.state.animatingOut) && ( 43 | 44 | ) 45 | ) 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/examples/BasicEnterExitExample/animations.js: -------------------------------------------------------------------------------- 1 | import anime from 'animejs' 2 | 3 | let currentAnimation 4 | 5 | export const animateIn = ListContainer => { 6 | if (currentAnimation) currentAnimation.pause() 7 | const items = [...ListContainer.querySelectorAll('.item')] 8 | items.forEach(c => { 9 | c.style.opacity = 0 10 | }) 11 | currentAnimation = anime 12 | .timeline() 13 | .add({ 14 | targets: ListContainer, 15 | translateX: [-1000, 0], 16 | opacity: [0, 1], 17 | duration: 500, 18 | elasticity: 100 19 | }) 20 | .add({ 21 | targets: items, 22 | duration: 400, 23 | opacity: [0, 1], 24 | translateY: [-30, 0], 25 | delay: (el, i) => i * 75 26 | }) 27 | } 28 | 29 | export const animateOut = (ListContainer, callback) => { 30 | if (currentAnimation) currentAnimation.pause() 31 | const items = ListContainer.querySelectorAll('.item') 32 | currentAnimation = anime 33 | .timeline() 34 | .add({ 35 | targets: items, 36 | duration: 100, 37 | opacity: 0, 38 | translateY: -30, 39 | delay: (el, i) => i * 50, 40 | easing: 'easeInOutSine' 41 | }) 42 | .add({ 43 | targets: ListContainer, 44 | translateX: 1000, 45 | opacity: [1, 0], 46 | duration: 1000, 47 | complete: callback, 48 | elasticity: 100 49 | }) 50 | } 51 | -------------------------------------------------------------------------------- /src/examples/BasicEnterExitExample/index.js: -------------------------------------------------------------------------------- 1 | import List from './../../components/List' 2 | import AddAnimationsHOC from './AddAnimationsHOC' 3 | import { animateIn, animateOut } from './animations' 4 | 5 | export default AddAnimationsHOC( 6 | animateIn, 7 | animateOut 8 | )(List) 9 | -------------------------------------------------------------------------------- /src/examples/GroupItemsExample/GroupItemsHOC.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import ReactDOM from 'react-dom' 3 | 4 | export default function addArrayAnimations (animateGroups) { 5 | return function wrapComponent (WrappedComponent) { 6 | return class AnimationHOC extends Component { 7 | componentWillReceiveProps (newProps) { 8 | if (newProps.group !== this.props.group) { 9 | this._initiateAnimation = animateGroups(this.child) 10 | } else { 11 | delete this._initiateAnimation 12 | } 13 | } 14 | 15 | componentDidUpdate () { 16 | this._initiateAnimation && this._initiateAnimation() 17 | } 18 | 19 | render () { 20 | const getRef = component => 21 | component && (this.child = ReactDOM.findDOMNode(component)) 22 | return 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/examples/GroupItemsExample/animations.js: -------------------------------------------------------------------------------- 1 | import anime from 'animejs' 2 | 3 | export function animateGroups (ListComponent) { 4 | const items = [...ListComponent.querySelectorAll('.item')] 5 | const oldPositionDict = items.reduce((acc, item) => { 6 | acc[item.dataset.id] = item.getBoundingClientRect() 7 | return acc 8 | }, {}) 9 | return function initiateAnimation () { 10 | const transformPositionDict = {} 11 | // make sure to get the new array -- React might have destroyed 12 | // and created new DOM nodes 13 | const items = [...ListComponent.querySelectorAll('.item')] 14 | items.forEach(item => { 15 | const oldPosition = oldPositionDict[item.dataset.id] 16 | const newPosition = item.getBoundingClientRect() 17 | const translateX = oldPosition.left - newPosition.left 18 | const translateY = oldPosition.top - newPosition.top 19 | item.style.transform = `translate(${translateX}px, ${translateY}px)` 20 | transformPositionDict[item.dataset.id] = { 21 | translateX: [translateX, 0], 22 | translateY: [translateY, 0] 23 | } 24 | }) 25 | anime({ 26 | targets: items, 27 | translateX: item => transformPositionDict[item.dataset.id].translateX, 28 | translateY: item => transformPositionDict[item.dataset.id].translateY, 29 | duration: 1000, 30 | delay: (item, i) => i * 12, 31 | easing: 'easeInOutElastic', 32 | elasticity: 1 33 | }) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/examples/GroupItemsExample/index.js: -------------------------------------------------------------------------------- 1 | import List from './../../components/List' 2 | import GroupItemsHOC from './GroupItemsHOC' 3 | import { animateGroups } from './animations' 4 | 5 | export default GroupItemsHOC( 6 | animateGroups 7 | )(List) 8 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | 2 | body { 3 | margin: 0; 4 | padding: 0; 5 | font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", 6 | "Roboto", "Helvetica Neue", Arial, sans-serif; 7 | line-height: 1.5; 8 | } 9 | 10 | .App { 11 | max-width: 593px; 12 | margin: 3rem 0 0 5rem; 13 | } 14 | 15 | .item { 16 | margin-right: 1rem; 17 | margin-bottom: 1rem; 18 | display: flex; 19 | align-items: center; 20 | justify-content: center; 21 | width: 3rem; 22 | height: 3rem; 23 | border: 0; 24 | border-radius: 0; 25 | transition: background-color 0.2s ease-in-out, border-radius 0.2s ease-in-out; 26 | color: white; 27 | } 28 | 29 | /* sometimes entering items should be invisible until they have an enter animation applied */ 30 | .item__invisible { 31 | opacity: 0; 32 | } 33 | 34 | .item__blue { 35 | background-color: #3185fc; 36 | } 37 | 38 | .item__red { 39 | background-color: #ee4266; 40 | } 41 | 42 | .item__green { 43 | background-color: #59c9a5; 44 | } 45 | 46 | .item__circle { 47 | border-radius: 100%; 48 | } 49 | 50 | .item__rounded { 51 | border-radius: 0.5rem; 52 | } 53 | 54 | .item__square { 55 | border-radius: 0; 56 | } 57 | 58 | .list { 59 | display: flex; 60 | flex-wrap: wrap; 61 | align-items: center; 62 | padding: 0; 63 | margin-bottom: 2rem; 64 | } 65 | 66 | .list--background { 67 | background: rgb(236, 236, 236); 68 | padding: 1rem 0 0 1rem; 69 | padding-right: 0; 70 | } 71 | 72 | .list li { 73 | list-style-type: none; 74 | } 75 | 76 | .list__update-explanation { 77 | margin-top: 1rem; 78 | } 79 | 80 | button { 81 | padding: 0.5rem 1rem; 82 | border: 1px solid rgb(185, 185, 185); 83 | font-size: 1rem; 84 | cursor: pointer; 85 | margin-bottom: 1rem; 86 | } 87 | 88 | button:hover, 89 | button:focus { 90 | background-color: rgba(185, 185, 185, 0.3); 91 | } 92 | 93 | label { 94 | margin-right: 1rem; 95 | } 96 | 97 | legend { 98 | font-weight: bold; 99 | } 100 | 101 | fieldset { 102 | border: 0; 103 | padding: 0; 104 | margin: 0; 105 | margin-bottom: 1rem; 106 | } 107 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import './index.css' 4 | import App from './App' 5 | 6 | ReactDOM.render(, document.getElementById('root')) 7 | -------------------------------------------------------------------------------- /src/stories/ArrayUpdateExampleWrapper.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import ListWithAnimation from './../examples/ArrayUpdateExample' 3 | import generateItems from './generateItems' 4 | 5 | function randomTrue(divisor) { 6 | return () => Math.random() <= 1 / divisor 7 | } 8 | 9 | const items = generateItems(75) 10 | 11 | class App extends Component { 12 | state = { 13 | items: items.filter(randomTrue(3)), 14 | unrelatedProp: Math.random().toFixed(5) 15 | } 16 | 17 | componentDidMount() { 18 | this.intervalId = setInterval(() => { 19 | this.setState({ unrelatedProp: Math.random().toFixed(5) }) 20 | }, 500) 21 | } 22 | componentWillUnmount() { 23 | this.intervalId && clearInterval(this.intervalId) 24 | } 25 | 26 | render() { 27 | return ( 28 |
    29 |
    30 | 40 |
    41 | 45 |
    46 | ) 47 | } 48 | } 49 | 50 | export default App 51 | -------------------------------------------------------------------------------- /src/stories/BasicEnterExitExampleWrapper.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import ListWithAnimation from './../examples/BasicEnterExitExample' 3 | import generateItems from './generateItems' 4 | 5 | class App extends Component { 6 | state = { 7 | items: generateItems(27), 8 | isVisible: false 9 | } 10 | componentDidMount() { 11 | this.intervalId = setInterval(() => { 12 | this.setState({ items: generateItems(27) }) 13 | }, 750) 14 | } 15 | componentWillUnmount() { 16 | this.intervalId && clearInterval(this.intervalId) 17 | } 18 | render() { 19 | return ( 20 |
    21 |

    22 | Item attributes are continually updating to show that the 23 | animation proceeds as normal even when children are being updated. 24 |

    25 |
    26 | {this.state.isVisible ? ( 27 | 30 | ) : ( 31 | 34 | )} 35 |
    36 | 37 | 42 |
    43 | ) 44 | } 45 | } 46 | 47 | export default App 48 | -------------------------------------------------------------------------------- /src/stories/GroupItemsExampleWrapper.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import ListWithAnimation from './../examples/GroupItemsExample' 3 | import generateItems from './generateItems' 4 | 5 | const groups = ['all', 'color', 'shape', 'even/odd'] 6 | 7 | class App extends Component { 8 | state = { 9 | items: generateItems(), 10 | group: 'all' 11 | } 12 | render() { 13 | const { items, group } = this.state 14 | let groupedItems = {} 15 | items.forEach(item => { 16 | if (groupedItems[item[group]]) groupedItems[item[group]].push(item) 17 | else groupedItems[item[group]] = [item] 18 | }) 19 | return ( 20 |
    21 |
    22 |
    { 24 | this.setState({ group: e.target.value }) 25 | }} 26 | > 27 | {groups.map(s => { 28 | return ( 29 | 38 | ) 39 | })} 40 |
    41 |
    42 | 43 |
    44 | ) 45 | } 46 | } 47 | 48 | export default App 49 | -------------------------------------------------------------------------------- /src/stories/generateItems.js: -------------------------------------------------------------------------------- 1 | const getRandomFromList = arr => arr[Math.floor(Math.random() * arr.length)] 2 | 3 | const colors = ['red', 'blue', 'green'] 4 | const shapes = ['square', 'circle', 'rounded'] 5 | 6 | export default function generateItems (len = 50) { 7 | const items = [...Array(len + 1).keys()].slice(1) 8 | return items.map(item => ({ 9 | id: item, 10 | color: getRandomFromList(colors), 11 | shape: getRandomFromList(shapes), 12 | 'even/odd': item % 2 === 0 13 | })) 14 | } 15 | -------------------------------------------------------------------------------- /src/stories/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { storiesOf } from '@storybook/react' 3 | 4 | import './../index.css' 5 | 6 | import BasicEnterExitExample from './BasicEnterExitExampleWrapper' 7 | import ArrayUpdateExample from './ArrayUpdateExampleWrapper' 8 | import GroupItemsExample from './GroupItemsExampleWrapper' 9 | 10 | storiesOf('Animation Examples', module) 11 | .add('1. The basic technique', () => ) 12 | .add('2. Object persistence', () => ) 13 | .add('3. Enter, update and delete transitions', () => ) 14 | --------------------------------------------------------------------------------