Build a Data-Driven Chart Animation for a Commercial Treatment
Build a Data-Driven Chart Animation for a Commercial Treatment Sample
A chart in a treatment has two jobs that pull against each other. It has to hold attention — which is what motion is for — and it has to keep every mark attached to the number it stands for, which is what revision is for. Treatments get revised. The figure you drew on Tuesday is 55 on Thursday, the deck goes out in the morning, and the question is whether the bar that moved is still Northport's bar.
The answer this article builds toward: write the values down as data with identities, units and a source status; map them through scales you have committed to in code rather than inferred from the array in front of you; bind every mark and every label to a category id; then animate only the presentation — the arrival, the arrangement — and keep the numbers themselves out of the interpolation. Finish with a static state where every bar and every number agree, because that state, not the animation, is what the deck actually hands over.
Underneath all of it sits one distinction. A reveal is not a measurement. A bar rising from the axis is a drawing being constructed. A bar travelling from 42 to 55 is a picture of two states that were never on screen together, and somewhere in the middle of that interpolation the bar's height corresponds to 48.5 — a value that belongs to no dataset anywhere. Motion can be honest about construction. It cannot be honest about history nobody observed.
Start with values, units, and a settled static chart
Begin with a small file, named so its status travels with it. This fixture uses three invented markets and an invented metric; nothing in it came from a client, and the filename says so.
id,label,users_thousands
northport,Northport,42
eastvale,Eastvale,9
southgate,Southgate,35
Four decisions live in those four lines. id is the identity that will survive every reorder and revision. label is what a reader sees — a different job, and one that changes more often than the id does. users_thousands puts the unit in the column header rather than in a comment somebody deletes, so no one reads 42 as 42 people or 42 million. And the filename carries the source status, which becomes the chart's subtitle: illustrative figures.
Then convert. A CSV hands you strings, and +d.users_thousands is the moment a quantity becomes a number. Skip it and d3.max compares text: with these three values, "9" beats "42" because "9" sorts above "4". A domain built from that string produces a chart that is wrong in a way that looks like a styling problem.
import * as d3 from "d3";
// 1. Values, with units and source status attached.
const rows = await d3.csv("./markets.synthetic.csv", d => ({
id: d.id, // stable identity, never shown to the reader
label: d.label, // display name
users: +d.users_thousands // quantity: thousands of weekly users
}));
Draw the static chart before you animate anything. Set the duration to zero, load the page, and read the three values off the screen. Everything in the next three sections is easier to check against a picture that isn't moving.
Map quantities and categories through explicit scales
Two scales, two separate jobs. D3's documentation describes a scale as a mapping between a data dimension and a visual encoding, and that framing is worth taking literally here: the quantity maps to length, the category maps to position, and the two mappings should be readable as distinct pieces of code. (The D3 pages cited here were checked on 2026-09-09; none of them speaks to your dataset.)
const W = 560, H = 320;
const M = { top: 32, right: 24, bottom: 48, left: 60 };
const plotW = W - M.left - M.right;
const plotH = H - M.top - M.bottom;
// The ceiling is a decision, not a measurement. Write it down.
const USERS_MAX = 60;
const y = d3.scaleLinear().domain([0, USERS_MAX]).range([plotH, 0]);
const x = d3.scaleBand().range([0, plotW]).paddingInner(0.4).paddingOuter(0.1);
USERS_MAX is the whole argument of this section. d3.max(data, d => d.users) is the habit to break here — not because it's wrong, but because it answers a question you didn't ask. How tall is the tallest bar is not the same question as what ceiling does this comparison need.
The difference is arithmetic, and it's the difference between a bar chart and a lie. With the domain fixed at 0–60, Northport at 42 fills 70.0% of the plot height and at 55 fills 91.7% — a ratio of about 1.31, matching the value's own ratio of 55 ÷ 42. When the axis shares a zero baseline and a common ceiling, the ratio of bar heights equals the ratio of values. Change the ceiling and that correspondence breaks. Fit the first chart to 0–50 because 42 was the maximum, then refit to 0–60 when the revision arrives, and the same 31% increase in the number renders as a 9% increase in the bar. A viewer reads a nudge where the data reports a third.
A fixed ceiling has a cost: if your values sit well below it, the bars look stunted and someone will ask you to zoom in. The honest answer is to name the ceiling — "60 is the target we're measuring against" — rather than to resize the axis silently. Either way, once written as a constant, a later revision cannot move it by accident.
Bind marks to identities, not their positions in a list
This is what the id column was for. D3's joining documentation describes binding records to elements, with a key function preserving item identity when values or order change.
const labelById = new Map(data.map(d => [d.id, d.label]));
// Bars: identity is the market id, not the array position.
g.select(".bars").selectAll("rect")
.data(data, d => d.id)
.join(
enter => enter.append("rect")
.attr("class", "bar")
.attr("x", d => x(d.id))
.attr("width", x.bandwidth())
.attr("y", plotH)
.attr("height", 0),
update => update,
exit => exit.transition(t).attr("height", 0).attr("y", plotH).remove()
)
.transition(t)
.attr("x", d => x(d.id))
.attr("width", x.bandwidth())
.attr("y", d => y(d.users))
.attr("height", d => plotH - y(d.users));
Key on d.id, not d.label. Labels get shortened, translated, corrected and re-capitalised, and a join keyed on the display string treats a renamed category as a brand new one — the old bar exits, a new one enters, and the transition shows a category being replaced when only its spelling changed.
Keying buys you two things. The first is that reordering the array moves the bars that already exist instead of dressing them in each other's data. The second is subtler and more useful: element identity and data identity stay aligned, so anything an element is carrying — a class, a tooltip, an event handler that captured its datum — keeps pointing at the category it was set for. Section five shows what happens when they drift apart.
Notice that the label map is rebuilt from the incoming data on every pass. A map built once from the first array will quietly go stale the first time a category is renamed, and the axis will keep printing the old name with complete confidence.
Animate a defined presentation change
Now the reveal. Entering bars start at the baseline and grow to their value; that is a drawing being assembled, and it is what motion in a treatment is actually good at.
// Labels ride with their bars, but the number lands only when the bar stops.
g.select(".bar-labels").selectAll("text")
.data(data, d => d.id)
.join("text")
.attr("class", "bar-label")
.attr("text-anchor", "middle")
.transition(t)
.attr("x", d => x(d.id) + x.bandwidth() / 2)
.attr("y", d => y(d.users) - 10)
.on("end", function () {
d3.select(this).text(d => d.users);
});
The number arrives on end, not inside the tween. D3's transition module interpolates supported properties between two states over a duration; text is a supported property, and a tweened label between 42 and 55 will happily print 43, 46, 51 on its way — numbers nobody measured. Tween the geometry, cut the numbers.
That leaves a real cost, and it should be stated rather than hidden: while a bar is in transit, its number is either the old one or the new one, and neither agrees with the length on screen. Two ways out. Set the label at the end, as above, so the bar arrives and then the number lands — during the motion the axis still gives the reader the scale, which is one good reason not to fade the axis out. Or show both real numbers: the old value as a small grey marker at the previous height, the new value on the bar. Either way, at least one frame must exist in which every mark and every number agree. For a static capture or a PDF export, render with duration zero so no such disagreeing frame is the one that gets saved.
One more rule for a treatment deck, where the watched thing is a claim and not a widget. Do not animate a value change with a stagger. A stagger reads as a sequence of events — Northport moved, then Eastvale responded — and no such sequence occurred. Same duration, same easing, all bars at once, or no motion on that change at all.
Revise one value and inspect the resulting chart
Here is the client's edit. Northport goes from 42 to 55, the domain holds at 60, and a ghost marks where the bar used to end.
function ghost(data, previous) {
const before = new Map(previous.map(d => [d.id, d.users]));
g.select(".ghosts").selectAll("line")
.data(data.filter(d => before.has(d.id)), d => d.id)
.join("line")
.attr("class", "ghost")
.attr("x1", d => x(d.id))
.attr("x2", d => x(d.id) + x.bandwidth())
.attr("y1", d => y(before.get(d.id)))
.attr("y2", d => y(before.get(d.id)));
}
const revised = rows.map(d => (d.id === "northport" ? { ...d, users: 55 } : d));
ghost(revised, rows);
render(revised);
The filter matters. A market present in both states gets a marker; a market that entered this round gets none, because a ghost drawn at the new height would claim that nothing changed.
Now check the endpoint against the arithmetic. Northport's bar should reach 91.7% of the plot height, up from 70.0% — a ratio of about 1.31 against a value ratio of 1.31. Southgate and Eastvale should not have moved at all. Every label should sit over the bar it names. If the numbers were tweened instead of cut, the transition would have displayed a series of Northport values that Northport never had.
Then run the diagnostic, which changes no values:
render([...revised].sort((a, b) => b.users - a.users));
Sorting is arrangement, not news. With a keyed join the bars travel to their new slots and the axis ticks travel with them, which is what makes the move read as a re-sort rather than as three markets suddenly trading places. Keep the axis moving in the same transition. If the ticks sat still while the bars slid underneath them, the picture would say the values changed and the labels did not.
The mismatched version, and exactly what it shows
Here is the same chart with one structural mistake: the label layer is drawn once, at setup, and never enters the update pass.
// Broken on purpose: labels drawn once, outside render().
function buildLabelsOnce(data) {
g.select(".bar-labels").selectAll("text")
.data(data) // no key: identity is array position
.join("text")
.attr("text-anchor", "middle")
.attr("x", d => x(d.id) + x.bandwidth() / 2)
.attr("y", d => y(d.users) - 10)
.text(d => d.users);
}
Setup runs with Northport 42, Eastvale 9, Southgate 35, so the numerals land as slot 0 42, slot 1 9, slot 2 35. Notice what the label layer does not contain: market names. buildLabelsOnce sets .text(d => d.users) and nothing else, so every name on this chart comes from the axis. Now apply the revision and the re-sort without re-running the labels. The bars become slot 0 Northport 55, slot 1 Southgate 35, slot 2 Eastvale 9, and the axis ticks travel with them, because the axis is inside the update pass even though the labels are not. The numerals never move.
The result is a chart that reads: the tallest bar is labelled 42, 9 sits above Southgate's bar standing at 35, and 35 sits above Eastvale's bar standing at 9. The names are still correct and still attached to the right slots; only the numbers are stale. That asymmetry is what makes them easy to believe. A viewer who reads the number rather than the bar now believes Eastvale has 35 thousand weekly users. It has nine. Nothing in the code threw an error, and the transition was smooth.
The fix is not to add a label transition. It is to move the label join inside render, keyed on d.id, so that no layer escapes the update pass. When you find one of these in a deck, check for siblings: an axis drawn once, a tooltip <title> appended at enter, a footnote string built from the first array. Any of them will hold a stale claim alongside a fresh bar.
The same species of error lives in element-carried state. Suppose the launch market gets an accent bar, set once in the enter branch:
.attr("class", d => (d.id === "eastvale" ? "bar bar--priority" : "bar"))
With a keyed join, Eastvale's rectangle keeps its class and moves to Eastvale's slot, which is correct. With an index-keyed join, the rectangle that entered as Eastvale receives Southgate's datum on the re-sort and keeps the accent — so the amber bar now sits on the wrong market. The element remembered something, and the element's identity had drifted. Re-derive anything category-specific on every pass, or key the join so it can't drift.
Before the sample leaves your hands, run the same checklist against it four times — on the initial static state, at the end of the revision, at the end of the re-sort, and in the PDF export. Are the values right? Are the labels over the bars they name? Is the identity of each bar the one the key says? Is the scale the domain you wrote down? A smooth animation that attaches the correct number to the wrong category is still wrong, and it is wrong in the one format nobody thinks to check.
The endpoint is the deliverable
Build the chart so that a revision is an edit to one row of a file rather than a redraw of a slide, and so the finished frame can be checked with no playback at all. That frame is what a screenshot, a PDF export, a printed leave-behind and any reading tool short of a video player will actually contain. If the values only exist at the end of a transition, everything except the live pitch sees nothing.
The fixture above is written to run; the arithmetic here was worked by hand rather than in a browser, so verify the endpoint yourself before the sample goes in front of anyone. Keep the missing-key version in the file, commented, next to the repaired one. When somebody edits a number at 11pm, the broken variant is what tells them which layer they forgot.
And keep the two claims separate in your own head, because a client will not. Binding marks to values makes the picture faithful to the dataset. It says nothing about whether the dataset is true, and an animated bar cannot tell the difference between a measurement and a placeholder. That is a different job, and it happens before this one.
Frequently asked questions
Why shouldn't the numbers themselves be tweened during a chart animation?
Interpolating text between values can print numbers nobody measured, such as 43, 46, or 51 on the way from 42 to 55. Tween the geometry and cut the numbers. Set the label at the end, and make sure at least one frame has every mark and number in agreement; for a static capture or PDF, render with duration zero.
Why use a fixed domain ceiling instead of d3.max?
d3.max answers how tall the tallest bar is, not what ceiling the comparison needs. A shared zero baseline and fixed ceiling keep bar-height ratios equal to value ratios. If you refit the axis after a revision, the same 31% increase in a number can render as only a 9% increase in the bar.
Why bind marks and labels to id rather than label or array position?
Labels change more often and a join keyed on a display string treats a renamed category as new. A keyed join preserves identity through reorders and revisions, and helps element-carried state stay with the right category. Rebuild any label map from the incoming data on every pass so it cannot go stale.
What goes wrong if labels are drawn once outside the render pass?
They never update. After a revision and re-sort, stale numbers can sit above correct bars and correct names from the axis, making a false claim such as a market having 35 when it has 9. The fix is to move the label join inside render and key it on id; also check sibling layers such as axes, tooltips, and footnotes.
What is the endpoint that should be delivered?
A static state where every bar and every number agree, because that is what screenshots, PDFs, printed leave-behinds, and non-video reading tools will contain. Run the checklist on the initial static state, the revision endpoint, the re-sort endpoint, and the PDF export. Binding marks to values makes the picture faithful to the dataset; it does not certify that the dataset itself is true.