Command Palette

Search for a command to run...

Hectal
PHASE 9Intermediate ~23 min· topic 1 of 6

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. 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 still 0 right 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. 2

    Use a colour name

    In the first example change "#e76f51" to "tomato". Leg 2 now ends with fill rgb(255, 99, 71). Names work because D3 asks the browser's colour parser what the name means, and the result is written as rgb(...).

  3. 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 as NaN, and the console prints Error: <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

Schedule, run and chain: what is true at each moment New tab

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

Press Run to see the page.

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: undefined
What an interpolator returns at t = 0, 0.5 and 1 New tab

These 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();.

terminal
$ Console
── what you'll see ──
leg 1 ended: width 253.72244488977955 fill #2a9d8f
Uncaught Error: too late; already running
leg 2 ended: width 253.72244488977955 fill rgb(231, 111, 81)
(the last line, "bookkeeping left on the bar", never appears: the script stopped at the error, while the bars still finish)

Break #2

Animate to a colour that is not a colour

In the first example change "#e76f51" in leg 2 to "greenish".

terminal
$ In the preview
── what you'll see ──
leg 2 ended: width 253.72244488977955 fill greenish
No error. All seven bars turn black on the first frame of leg 2 and stay black.

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 one requestAnimationFrame loop, 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 transform and opacity can 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's d, a bar's width, 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. 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. 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 read 0 right after the call, and they still read 0 at 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. 3

    What happens on each frame. Every element's record owns a d3.timer, which wakes on requestAnimationFrame, 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 number t from 0 to 1 and returns a value. On each frame it computes t = ease(elapsed ÷ duration) and writes interpolator(t). On the last frame it writes exactly t = 1, so the end value is exact, fires the end event and deletes its own record. After the first example finishes, element.__transition is undefined again.

  4. 4

    What a transition can animate. For .attr() and .style() D3 picks the interpolator from the end value: a number uses interpolateNumber, anything that is a colour (#e76f51, tomato, rgb(1,2,3)) uses interpolateRgb, and everything else uses interpolateString. interpolateString finds the numbers in the start text and in the end text, pairs them in order and moves each pair, keeping the words between them. So translate(10,20) to translate(50,0) works, and 12px to 20px works. The transform attribute has its own interpolator that splits a transform into move, rotate and scale.

  5. 5

    What it cannot animate. Text, a class name, display and visibility have 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 as rgb(…): after a transition to #e76f51 the attribute reads rgb(231, 111, 81), which matters if a test or a CSS selector compares text.

  6. 6

    Delay, duration, ease and chaining. .duration(ms) defaults to 250, .delay(ms) to 0, and the easing (Topic 9.2) to d3.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) or await 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 throws too late; already running.

  7. 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 of 0 still 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 of undefined or null makes D3 remove the attribute (a rect without width is not drawn). An end value of NaN is written as the text NaN on every frame: the browser prints Error: <rect> attribute width: Expected length, "NaN". once per frame (about 30 times in a 500 ms transition), and the bar ends with width NaN. Clean the data before the transition, not inside it.

Explain it without notes

01

Right after selection.transition().attr("width", 254), what is the element's width, and why?

02

What does a transition do on each frame?

03

After a transition to #e76f51, the fill attribute reads rgb(231, 111, 81). Why, and when does it matter?

Practice

01

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.

02

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).