├── typing.gif ├── public ├── favicon.ico ├── manifest.json └── index.html ├── src ├── index.js ├── components │ ├── Alphabet.jsx │ ├── FancyText.jsx │ └── Letter.jsx └── App.jsx ├── .gitignore ├── package.json ├── LICENSE └── README.md /typing.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Swizec/react-d3-enter-exit-transitions/HEAD/typing.gif -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Swizec/react-d3-enter-exit-transitions/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render } from "react-dom"; 3 | import App from "./App"; 4 | 5 | render(, document.getElementById("root")); 6 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | npm-debug.log 4 | dist# See https://help.github.com/ignore-files/ for more about ignoring files. 5 | 6 | # dependencies 7 | /node_modules 8 | 9 | # testing 10 | /coverage 11 | 12 | # production 13 | /build 14 | 15 | # misc 16 | .DS_Store 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-d3-enter-exit-transitions", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "d3": "^5.7.0", 7 | "gh-pages": "^2.0.1", 8 | "react": "^16.5.2", 9 | "react-dom": "^16.5.2", 10 | "react-transition-group": "^2.5.0" 11 | }, 12 | "devDependencies": { 13 | "react-scripts": "1.0.17" 14 | }, 15 | "scripts": { 16 | "start": "react-scripts start", 17 | "build": "react-scripts build", 18 | "test": "react-scripts test --env=jsdom", 19 | "eject": "react-scripts eject", 20 | "predeploy": "yarn run build", 21 | "deploy": "gh-pages -d build" 22 | }, 23 | "homepage": "https://swizec.github.io/react-d3-enter-exit-transitions" 24 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 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 | -------------------------------------------------------------------------------- /src/components/Alphabet.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import * as d3 from "d3"; 3 | import { TransitionGroup } from "react-transition-group"; 4 | 5 | import Letter from "./Letter"; 6 | 7 | class Alphabet extends React.Component { 8 | static letters = "abcdefghijklmnopqrstuvwxyz".split(""); 9 | state = { alphabet: [] }; 10 | 11 | componentDidMount() { 12 | d3.interval(this.shuffleAlphabet, 1500); 13 | } 14 | 15 | shuffleAlphabet = () => { 16 | const alphabet = d3 17 | .shuffle(Alphabet.letters) 18 | .slice(0, Math.floor(Math.random() * Alphabet.letters.length)) 19 | .sort(); 20 | 21 | this.setState({ 22 | alphabet 23 | }); 24 | }; 25 | 26 | render() { 27 | let transform = `translate(${this.props.x}, ${this.props.y})`; 28 | 29 | return ( 30 | 31 | 32 | {this.state.alphabet.map((d, i) => ( 33 | 34 | ))} 35 | 36 | 37 | ); 38 | } 39 | } 40 | 41 | export default Alphabet; 42 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 22 | React App 23 | 24 | 25 | 28 |
29 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /src/App.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | 3 | import Alphabet from "./components/Alphabet"; 4 | import FancyText from "./components/FancyText"; 5 | 6 | class App extends Component { 7 | state = { text: "" }; 8 | 9 | changeText(event) { 10 | this.setState({ text: event.target.value }); 11 | } 12 | 13 | render() { 14 | return ( 15 |
16 |

17 | Animated typing built with React 16+, D3js v5, and 18 | react-transition-group v2 19 |

20 |

21 | Type some text. Delete stome stuff. Watch enter/update/exit 22 | transitions at play. 23 |

24 |

25 | Inspired by Bostock's block{" "} 26 | 27 | General Update Pattern 4.0 28 | 29 |

30 | 42 | 43 | 44 | {/* */} 45 | 46 |
47 | ); 48 | } 49 | } 50 | 51 | export default App; 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # React d3 enter/exit animations 3 | 4 | ![Gif of end result](typing.gif) 5 | 6 | This is a proof of concept implementation of declarative enter-update-exit 7 | transitions built with React and d3js. 8 | 9 | The main `FancyText` component's render method is completely 10 | declarative. It renders `Letter`s using a naive loop. 11 | 12 | ```javascript 13 | render() { 14 | let transform = `translate(${this.props.x}, ${this.props.y})`; 15 | 16 | return ( 17 | 18 | 19 | {this.state.alphabet.map((d, i) => ( 20 | 21 | ))} 22 | 23 | 24 | ); 25 | } 26 | ``` 27 | 28 | Each `Letter` component then uses lifecycle hooks from 29 | `ReactTransitionGroup` to do transitions. 30 | 31 | ```javascript 32 | class Letter extends Component { 33 | state = { 34 | y: -60, 35 | className: 'enter', 36 | fillOpacity: 1e-6 37 | } 38 | 39 | componentWillEnter(callback) { 40 | // start enter transition, then callback() 41 | } 42 | 43 | componentWillLeave(callback) { 44 | // start exit transition, then callback() 45 | } 46 | 47 | componentWillReceiveProps(nextProps) { 48 | if (this.props.i != nextProps.i) { 49 | // start update transition 50 | } 51 | } 52 | 53 | render() { 54 | return ( 55 | 60 | {this.props.d} 61 | 62 | ); 63 | } 64 | }; 65 | 66 | ``` 67 | 68 | Based on Mike Bostock's 69 | [General Update Pattern 4.0](https://bl.ocks.org/mbostock/a8a5baa4c4a470cda598) 70 | block. 71 | 72 | ## License 73 | 74 | CC0 (public domain) 75 | -------------------------------------------------------------------------------- /src/components/FancyText.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { TransitionGroup } from "react-transition-group"; 3 | import * as d3 from "d3"; 4 | 5 | import Letter from "./Letter"; 6 | 7 | class FancyText extends Component { 8 | state = { 9 | textWithIds: [], 10 | lastId: 0 11 | }; 12 | 13 | componentDidUpdate(prevProps) { 14 | if (prevProps.text === this.props.text) return; 15 | 16 | const oldText = this.state.textWithIds; 17 | const newText = this.props.text.split(""); 18 | let indexOfChange = 0, 19 | sizeOfChange = 0, 20 | newLastId = this.state.lastId; 21 | 22 | // find change 23 | for ( 24 | ; 25 | newText[indexOfChange] == 26 | (oldText[indexOfChange] && oldText[indexOfChange][0]); 27 | indexOfChange++ 28 | ); 29 | 30 | // calculate size of change 31 | if (newText.length > oldText.length) { 32 | while ( 33 | newText[indexOfChange + sizeOfChange] != 34 | (oldText[indexOfChange] && oldText[indexOfChange][0]) && 35 | indexOfChange + sizeOfChange < newText.length 36 | ) { 37 | sizeOfChange = sizeOfChange + 1; 38 | } 39 | } else { 40 | while ( 41 | newText[indexOfChange] != 42 | (oldText[indexOfChange + sizeOfChange] && 43 | oldText[indexOfChange + sizeOfChange][0]) && 44 | indexOfChange + sizeOfChange < oldText.length 45 | ) { 46 | sizeOfChange = sizeOfChange + 1; 47 | } 48 | } 49 | 50 | // use existing ids up to point of change 51 | d3.range(0, indexOfChange).forEach(i => (newText[i] = oldText[i])); 52 | 53 | // use new ids for additions 54 | if (newText.length > oldText.length) { 55 | d3.range(indexOfChange, indexOfChange + sizeOfChange).forEach(i => { 56 | let letter = newText[i]; 57 | newText[i] = [letter, newLastId++]; 58 | }); 59 | 60 | // use existing ids from change to end 61 | d3.range(indexOfChange + sizeOfChange, newText.length).forEach( 62 | i => (newText[i] = oldText[i - sizeOfChange]) 63 | ); 64 | } else { 65 | // use existing ids from change to end, but skip what's gone 66 | d3.range(indexOfChange, newText.length).forEach( 67 | i => (newText[i] = oldText[i + sizeOfChange]) 68 | ); 69 | } 70 | 71 | this.setState({ 72 | textWithIds: newText, 73 | lastId: newLastId 74 | }); 75 | } 76 | 77 | render() { 78 | let { x, y } = this.props; 79 | 80 | return ( 81 | 82 | 83 | {this.state.textWithIds.map(([l, id], i) => ( 84 | 85 | ))} 86 | 87 | 88 | ); 89 | } 90 | } 91 | 92 | export default FancyText; 93 | -------------------------------------------------------------------------------- /src/components/Letter.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import * as d3 from "d3"; 3 | import Transition from "react-transition-group/Transition"; 4 | 5 | const ExitColor = "brown", 6 | UpdateColor = "#333", 7 | EnterColor = "green"; 8 | 9 | class Letter extends React.Component { 10 | defaultState = { 11 | y: -60, 12 | x: this.props.index * 32, 13 | color: EnterColor, 14 | fillOpacity: 1e-6 15 | }; 16 | state = this.defaultState; 17 | letterRef = React.createRef(); 18 | 19 | onEnter = () => { 20 | // Letter is entering the visualization 21 | let node = d3.select(this.letterRef.current); 22 | 23 | node.transition() 24 | .duration(750) 25 | .ease(d3.easeCubicInOut) 26 | .attr("y", 0) 27 | .style("fill-opacity", 1) 28 | .on("end", () => { 29 | this.setState({ 30 | y: 0, 31 | fillOpacity: 1, 32 | color: UpdateColor 33 | }); 34 | }); 35 | }; 36 | 37 | onExit = () => { 38 | // Letter is dropping out 39 | let node = d3.select(this.letterRef.current); 40 | 41 | node.style("fill", ExitColor) 42 | .transition(this.transition) 43 | .attr("y", 60) 44 | .style("fill-opacity", 1e-6) 45 | .on("end", () => this.setState(this.defaultState)); 46 | }; 47 | 48 | componentDidUpdate(prevProps, prevState) { 49 | if (prevProps.in !== this.props.in && this.props.in) { 50 | // A new enter transition has begun 51 | this.setState({ 52 | x: this.props.index * 32 53 | }); 54 | } else if (prevProps.index !== this.props.index) { 55 | // Letter is moving to a new location 56 | let node = d3.select(this.letterRef.current), 57 | targetX = this.props.index * 32; 58 | 59 | node.style("fill", UpdateColor) 60 | .transition() 61 | .duration(750) 62 | .ease(d3.easeCubicInOut) 63 | .attr("x", targetX) 64 | .on("end", () => 65 | this.setState({ 66 | x: targetX, 67 | color: UpdateColor 68 | }) 69 | ); 70 | } 71 | } 72 | 73 | render() { 74 | const { x, y, fillOpacity, color } = this.state, 75 | { letter } = this.props; 76 | 77 | return ( 78 | 85 | 96 | {letter} 97 | 98 | 99 | ); 100 | } 101 | } 102 | 103 | export default Letter; 104 | --------------------------------------------------------------------------------