Remember that dancing fractal tree from December? That was built using what I like to call The Rich Animation Technique. TRAT for short.
Yes, I just came up with that. You might know of it as the "game loop" principle, or recalculate-redraw. It's how React's engine works.
When applied to animation, TRAT follows the idea that, if you update props 60 times per second and trigger a React re-render every time, animation happens. It's kinda crazy that it works so well, but work it does.
It's a spectacular approach when you need fine-grained control like making a dragged node follow the mouse or running an entire Space Invaders game.
But TRAT is annoying when you don't want or need fine control. When you just want a tiny animation that does a thing and doesn't bother you with the details, it’s a bit cumbersome.
Pretty, isn't it? ?
It's a 50-by-50 field of black SVG <circle>
elements. On mouse over, a D3 transition takes over and increases a circle's radius to 20, then back down to 5. While that's happening, we switch on colorization to make it pretty.
Colors come from D3's interpolateWarm
scale laid out in a radial pattern. The radial pattern is high school math. Let me show you.
You can play with the code on CodePen, tweak params, try the dancing rainbow snake. I think it looks like a snake… also, I love emergent patterns like this. Transition each circle individually, and it looks like a connected blob.
See the Pen React D3 transition animation - rainbow field by Swizec Teller (@swizec) on CodePen.
We have two components: App
and Dot
. App sets up the grid of dots, and Dot
handles each circle's transitions and coloration.
I tried adding a fly-in transition as well, but ReactTransitionGroup dies on 2500 nodes. ? I have a video of that fail somewhere on snapchat.
App
The App component needs only a render
method that returns an SVG.
render() {
const width = 600,
N = 50,
pos = d3.scalePoint()
.domain(d3.range(N))
.range([0, width])
.padding(5)
.round(true);
return (
<svg width="600" height="600">
{d3.range(N).map(x =>
d3.range(N).map(y =>
<dot x={pos(x)} y={pos(y)} key={`${x}-${y}`} maxpos={width}></dot>
))}
</svg>
)
}
We're rendering a 600px-by-600px SVG with 50 nodes per row and column. We use D3's scalePoint
for dot positioning because it does everything we need. Makes sure they're evenly spaced, gives them padding on the sides, and ensures coordinates are rounded numbers.
Here's a diagram of how scalePoint
works:
To render the grid, we use two nested loops going from 0 to N. d3.range
builds an array for us so we can .map
over it. We return a ``component for each iteration.
Looking at this code: x={pos(x)} y={pos(y)}
, you can see why D3 scales are so neat. All positioning calculation boiled down to a 1-parameter function call. \o/
Dot
The Dot component has a few more moving parts. It needs a constructor
, a transition callback – flash
, a color
getter, and a render
method.
class Dot extends Component {
constructor(props) {
super(props);
this.state = Object.assign({},
props,
{r: 5});
}
flash() {
// transition code
}
get color() {
// color calculation
}
render() {
const { x, y, r, colorize } = this.state;
return <circle cx={x} cy={y} r={r} ref="circle" onmouseover={this.flash.bind(this)} style="{{fill:" colorize="" ?="" this.color="" :="" 'black'}}="">
}
}
</circle>
We initialize state in the component constructor
. The quickest approach is to copy all props
to state
, even though we don't need all props to be in state.
Normally, you want to avoid state and render all components from props. Functional principles, state is bad, and all that. But as Freddy Rangel likes to say "State is for props that change over time".
Guess what transitions are… props that change over time :)
So we put props in state and render from state. This lets us keep a stable platform while running transitions. It ensures that changes re-rendering Dot
from above won't interfere with D3 transitions.
Not super important in our particular example because those changes never happen. But I had many interesting issues in this animated typing example.
For the render
method, we return an SVG ``element positioned at (x, y)
, with a radius, an onMouseOver
listener, and a style with the fill
color depending on state.colorize
.
flash() – the transition
When you mouse over one of the dots, its flash()
method gets called as an event callback. This is where the transition happens that pops the circle bigger, then back to normal size.
flash() {
let node = d3.select(this.refs.circle);
this.setState({colorize: true});
node.transition()
.attr('r', 20)
.duration(250)
.ease(d3.easeCubicOut)
.transition()
.attr('r', 5)
.duration(250)
.ease(d3.easeCubicOut)
.on('end', () => this.setState({colorize: false}));
}
Look at those triple-nested anonymous callbacks. Does it give you shivers? It's okay for small experiments, but don't do it too much in real code. Callback hell is real.
Here's what happens:
- We
d3.select
the ``node. This enables D3 to take over the rendering of this particular DOM node - We
setState
to enable colorization. Yes, this triggers a re-render. - We start a
transition
that changes ther
attribute to20
pixels over a duration of250
milliseconds. - We add a
easeCubicOut
easing function, which makes the animation look more natural - When the transition ends, we start another similar transition, but change
r
back to5
. - When that's done, we turn off colorization and trigger another re-render.
If our transition didn't return things back to normal, I would use that 'end'
opportunity to sync React component state with reality. Something like this.setState({r: 20})
or whatever. Depends on what you're doing.
get color() – the colorization
Colorization doesn't have anything to do with transitions, but I want to explain how it works. Mostly to remind you that high school math, which you thought you'd never use again, is useful.
Here's what the colored grid looks like:
Colors follow a radial pattern even though d3.interpolateWarm
takes a single argument in the [0, 1]
range. We achieve the pattern using circle parametrization.
x^2 + y^2 = r^2
Calibrate a linear scale to translate between [0, maxR^2]
and [0, 1]
, then feed it x^2 + y^2
and you get the interpolateWarm
parameter. Magic :)
get color() {
const { x, y, maxPos } = this.state;
const t = d3.scaleLinear()
.domain([0, 1.2*maxPos**2])
.range([0, 1]);
return d3.interpolateWarm(t(x**2 + y**2));
}
We calibrate the t
scale to 1.2*maxPos**2
for two reasons. First, you want to avoid square roots whenever possible because they're slow. Second, adding the 1.2
factor changes how the color scale behaves and makes it look better.
For example, here it is with a factor of 0.05:
Play around and try different color scales, too. :)
Potential improvements
There are a bunch of things we could've done in this example but didn't. One of them is the radius parameter.
We really should have taken the r
parameter in as a property on <Dot>
, saved it in state as a, say, baseR
, then made sure the transition returns our dot back to that instead of a magic 5
number. Avoid peppering your code with random numbers.
Another improvement could be rendering more circles to provide a tighter grid. That doesn't work so well on CodePen, however.
PS: 6400 SVG nodes breaks @CodePen pic.twitter.com/9u9ATaGHJh
— Swizec Teller (@Swizec) February 9, 2017
And I already mentioned the problems with ReactTransitionGroup
that prevent us from making a nice load animation.
One idea I did have that would be really cool is to turn this colored grid into a music visualization thing. Not sure how to do that though. Is there a way to detect sound in a browser?
Could be a fun weekend experiment if there is ?
Continue reading about A Dancing Rainbow Snake – An Example of Minimal React and D3v4 transitions
Semantically similar articles hand-picked by GPT-4
- Dancing tree fractal with React
- Livecoding #18: An abstract React transition component
- Animating with React, Redux, and d3
- Livecoding #34: A Map of Global Migrations, Part 3
- Animating 2048 SVG nodes in React, Preact, Inferno, Vue, Angular 2, and CycleJS – a side-by-side comparison
Learned something new?
Read more Software Engineering Lessons from Production
I write articles with real insight into the career and skills of a modern software engineer. "Raw and honest from the heart!" as one reader described them. Fueled by lessons learned over 20 years of building production code for side-projects, small businesses, and hyper growth startups. Both successful and not.
Subscribe below 👇
Software Engineering Lessons from Production
Join Swizec's Newsletter and get insightful emails 💌 on mindsets, tactics, and technical skills for your career. Real lessons from building production software. No bullshit.
"Man, love your simple writing! Yours is the only newsletter I open and only blog that I give a fuck to read & scroll till the end. And wow always take away lessons with me. Inspiring! And very relatable. 👌"
Have a burning question that you think I can answer? Hit me up on twitter and I'll do my best.
Who am I and who do I help? I'm Swizec Teller and I turn coders into engineers with "Raw and honest from the heart!" writing. No bullshit. Real insights into the career and skills of a modern software engineer.
Want to become a true senior engineer? Take ownership, have autonomy, and be a force multiplier on your team. The Senior Engineer Mindset ebook can help 👉 swizec.com/senior-mindset. These are the shifts in mindset that unlocked my career.
Curious about Serverless and the modern backend? Check out Serverless Handbook, for frontend engineers 👉 ServerlessHandbook.dev
Want to Stop copy pasting D3 examples and create data visualizations of your own? Learn how to build scalable dataviz React components your whole team can understand with React for Data Visualization
Want to get my best emails on JavaScript, React, Serverless, Fullstack Web, or Indie Hacking? Check out swizec.com/collections
Did someone amazing share this letter with you? Wonderful! You can sign up for my weekly letters for software engineers on their path to greatness, here: swizec.com/blog
Want to brush up on your modern JavaScript syntax? Check out my interactive cheatsheet: es6cheatsheet.com
By the way, just in case no one has told you it yet today: I love and appreciate you for who you are ❤️