Topic 9.1
Transitions
In one line
A D3 transition does not move anything by itself. It stores a plan on each element, and a clock then writes a new value between the old one and the new one on every frame. It can animate numbers, colours and any text that contains numbers.
Think of it like this
A train timetable. You do not push the train. You write one line: leave at 9:00, arrive at 9:05, platform 3. The railway's clock then moves the train, minute by minute, and tells the station when it has arrived. A D3 transition is that timetable line: which elements, what they should end up as, and how long it may take. D3's timer does the pushing.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- transition
- A plan that moves some elements from their current values to new values over a time, run by D3's timer.
- interpolator
- A function that takes a number from 0 to 1 and returns a value in between two ends, such as 0 → 254.
- tween
- Short for "in-betweening": the function a transition calls on every frame that writes one value (an attribute, a style or text).
- frame
- One redraw of the screen, usually 1/60 of a second. A transition writes one set of values per frame.
- duration
- How many milliseconds the movement takes from start to end.
Step by step
01Draw the final bars, then ask for motion
The first example draws last week's rides with the bars at width 0, then asks for the real widths with bars.transition().duration(500).attr("width", ...). The line only schedules: the Console shows the widths are still 0 right after it.
02Watch the record on the element
While the transition waits, each bar has one schedule in __transition. Chaining adds a second one. When both have ended, the property is gone: the element is clean again.
03Chain a second leg
.transition() on a transition makes leg 2, which starts when leg 1 ends. It is how D3 says "then".
04Read what one value does
The second example prints what D3 writes at t = 0, 0.5 and 1 for numbers, colours, lengths and path text. Colours come out as rgb(...), dates and arrays need your own tween.
Try it yourself
- 1
Make it slower and see what stays the same
In the first example change
.duration(500)to.duration(1200). The numbers in the Console do not change: the widths are still0right after the call and the end values are the same. Only the time between them grows: the two "ended" lines now appear more than a second later. - 2
Use a colour name
In the first example change
"#e76f51"to"tomato". Leg 2 now ends withfill rgb(255, 99, 71). Names work because D3 asks the browser's colour parser what the name means, and the result is written asrgb(...). - 3
Send one bad value
In the first example change leg 1 to
.attr("width", (d, i) => (i === 0 ? NaN : x(d.rides))). Monday's bar never grows: its width ends asNaN, and the console printsError: <rect> attribute width: Expected length, "NaN".once per frame (about 30 lines). The other six bars are fine, so the chart looks nearly right: the error lines are the only sign. The exercises in this phase check for exactly this (viz.clean()).
Code & diagrams
The values come from the elements themselves. Each log runs inside an event, so the output is the same on every run and every machine.
Preview
Console
Nothing logged yet.Expected console output
right after .transition(), widths: 0,0,0,0,0,0,0
schedules stored on the first bar: 1
schedules stored on the first bar now: 2
leg 1 ended: width 253.72244488977955 fill #2a9d8f
leg 2 ended: width 253.72244488977955 fill rgb(231, 111, 81)
bookkeeping left on the bar: undefinedThese are the same interpolators transitions use. One difference: transition.attr and transition.style only know numbers, colours and strings (a date or an array is turned into text first). Dates and arrays need your own tween (Topic 9.4).
Console
(nothing was printed)Expected console output
number 0 | 127 | 254
colour "rgb(42, 157, 143)" | "rgb(137, 134, 112)" | "rgb(231, 111, 81)"
name to hex "rgb(70, 130, 180)" | "rgb(163, 193, 218)" | "rgb(255, 255, 255)"
string "translate(10,20)" | "translate(30,10)" | "translate(50,0)"
lengths "12px" | "16px" | "20px"
path "M0,0L10,10" | "M0,0L25,10" | "M0,0L40,10"
date 2025-01-01 | 2025-01-02 | 2025-01-03
array [0,10] | [5,20] | [10,30]
words "no change" | "no change" | "no change"Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Ask for the promise too late
In the first example replace const done = leg2.end(); and await done; by await leg1.end(); and await leg2.end();.
Break #2
Animate to a colour that is not a colour
In the first example change "#e76f51" in leg 2 to "greenish".
Myth vs fact
Myth
A transition moves the elements for you, so the code after it runs once they have arrived.
Fact
The line that creates the transition returns at once. The elements move later, on the browser's frames. Anything that must happen after the movement goes into an end event or after await transition.end().
Myth
D3 animates with CSS transitions.
Fact
No. D3 writes a new attribute or style value on every frame from JavaScript. That is why it can animate SVG attributes such as d and width, and why it runs on the main thread.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Every element of every transition has its own
d3.timer, but they all share onerequestAnimationFrameloop, and a frame's work is the sum of all the writes. At a few hundred elements this is cheap; at several thousand the writes (and the browser's layout and paint) can take longer than a 16.7 ms frame, and the animation stutters. That is one of the reasons Canvas exists (Topic 12.1 measures it on your own machine). - ▸
CSS transitions and animations on
transformandopacitycan run on the browser's compositor thread and stay smooth when JavaScript is busy. A D3 transition cannot: it needs the main thread (the one thread that also runs all your other JavaScript and answers clicks) on every frame. For one simple fade on an HTML element, CSS is the cheaper tool. D3 is for values CSS cannot reach (a path'sd, a bar'swidth, a number counting up). - ▸
The start value is read when the transition starts, not when it is created. If something changes the attribute between those two moments (another transition's first frame, your own code), the animation starts from the new value. This is why an interrupted bar continues smoothly from where it stood (Topic 9.3).
Remember this
- 1
The problem this solves: when a chart changes in a single frame, the eye cannot tell what moved. A bar that jumps from 2,979 to 3,992 looks like a different bar. A transition spreads the change over a few hundred milliseconds, so the eye follows where each mark went.
- 2
A transition schedules, it does not move.
selection.transition()creates a transition object and stores a small record on every selected element (element.__transition, a plain object keyed by the transition's id number). In the first example the widths still read0right after the call, and they still read0at the start event. The values you give (.attr("width", 254)) are the end values. The start value is read from the element when the transition starts, not when your code runs. - 3
What happens on each frame. Every element's record owns a
d3.timer, which wakes onrequestAnimationFrame, about 60 times a second. The record walks through states: scheduled, started, running, ended. At the start it reads the current attribute and builds an interpolator, a function that takes a numbertfrom 0 to 1 and returns a value. On each frame it computest = ease(elapsed ÷ duration)and writesinterpolator(t). On the last frame it writes exactlyt = 1, so the end value is exact, fires theendevent and deletes its own record. After the first example finishes,element.__transitionisundefinedagain. - 4
What a transition can animate. For
.attr()and.style()D3 picks the interpolator from the end value: a number usesinterpolateNumber, anything that is a colour (#e76f51,tomato,rgb(1,2,3)) usesinterpolateRgb, and everything else usesinterpolateString.interpolateStringfinds the numbers in the start text and in the end text, pairs them in order and moves each pair, keeping the words between them. Sotranslate(10,20)totranslate(50,0)works, and12pxto20pxworks. Thetransformattribute has its own interpolator that splits a transform into move, rotate and scale. - 5
What it cannot animate. Text, a class name,
displayandvisibilityhave no in-between values.transition.text()and a class change are written on the first frame and the element jumps. A colour name that does not exist ("greenish") is not a colour, so the string interpolator writes it at once. And the colour is always written back asrgb(…): after a transition to#e76f51the attribute readsrgb(231, 111, 81), which matters if a test or a CSS selector compares text. - 6
Delay, duration, ease and chaining.
.duration(ms)defaults to 250,.delay(ms)to 0, and the easing (Topic 9.2) tod3.easeCubicInOut. Calling.transition()on a transition chains: the new one starts when the first one ends, inherits its duration and easing, and resets its delay to 0. To wait for the end, use.on("end", fn)(one call per element) orawait transition.end(), a promise that resolves when every element is done and rejects if any element was interrupted or cancelled (Topic 9.3). Ask for it right away: a promise made after the transition started throwstoo late; already running. - 7
Edge cases, measured. A transition on an empty selection does nothing, and its
end()promise resolves at once, so empty data needs no special case here. A duration of0still runs: it writes the end value on its first frame. If the end value equals the value already on the element, nothing is scheduled to change. A missing value is the dangerous one. An end value ofundefinedornullmakes D3 remove the attribute (arectwithoutwidthis not drawn). An end value ofNaNis written as the textNaNon every frame: the browser printsError: <rect> attribute width: Expected length, "NaN".once per frame (about 30 times in a 500 ms transition), and the bar ends with widthNaN. Clean the data before the transition, not inside it.
Explain it without notes
Right after selection.transition().attr("width", 254), what is the element's width, and why?
What does a transition do on each frame?
After a transition to #e76f51, the fill attribute reads rgb(231, 111, 81). Why, and when does it matter?
Practice
Write grow(bars, x) that starts the width transition on bars (a selection of rects with data { rides }) and returns a promise that resolves when every bar is at its width. Log bars.nodes().map((n) => Math.round(n.getAttribute("width"))) after it.
Make the bars grow first (400 ms), and then turn Monday's bar orange in a second leg (300 ms) while the others stay teal. Log the fill of Monday's bar when the second leg ends.
Trade-offs
- ↔
Motion helps the eye follow a change, but every animation costs the reader time. A 300 to 500 ms transition on a change the reader asked for is cheap. A transition on every hover or every data refresh is noise (Topic 9.2).
- ↔
D3 transitions can animate anything you can write as a value, at the price of running on the main thread. CSS transitions are smoother for simple fades and moves, and cannot animate SVG path data or tween a number in text.
Done when you can
I can say what the line
selection.transition()does at the moment it runs, and what happens later.I can say which interpolator D3 picks for a number, a colour and other text.
I can name things that jump instead of moving and say why.
I can chain two legs and wait for the end with
await transition.end()without it being too late.I know the default duration (250 ms), delay (0) and easing (cubic in-out).