d3
D3 is a low-level toolkit for building custom data visualizations in the browser. Instead of chart types it gives you primitives: scales that map data to pixels, selections that bind data to DOM and SVG elements, shape generators for lines and arcs, plus force layouts, geo projections, transitions, and zoom behavior. You assemble every chart yourself, which is both the point and the price. Most higher-level chart libraries are built on top of its modules.
Unmatched when the visualization is the product and nothing off the shelf fits. For ordinary charts it is the expensive route; reach for Observable Plot or a chart library first and drop down to D3 modules when they run out.
Use it if
- The chart you need does not exist in any charting library and you must control every pixel
- You are building bespoke interactive graphics with custom transitions, brushing, or zooming, editorial-style work
- You only need one utility: d3-scale, d3-array, or d3-format installs standalone and is useful under any rendering stack
- You need force-directed layouts, hierarchies, or map projections, areas where D3 is still the reference implementation
- You need standard bar, line, or pie charts on a dashboard: Observable Plot or Chart.js gets you there in a fraction of the code
- You are in React and want the framework to own the DOM: D3 selections fight React's rendering model, and every hybrid pattern is a compromise
- You cannot afford the learning curve: the full library spans roughly 30 modules and about 90 kB gzipped, and fluency takes weeks, not hours
- You want a fast-moving project: v7 has been the current major since 2021 and activity is mostly maintenance, with the authors' newer energy going to Observable Plot
Setup reality
npm install d3 pulls the whole bundle of about 30 sub-modules, roughly 90 kB gzipped, so production apps usually install d3-scale, d3-selection and friends individually instead. D3 has been ESM-only since v7, which still breaks older Jest and CommonJS setups until you configure transforms. The real cost is conceptual: nothing renders until you understand selections, data joins, and scales, and a large share of the tutorials you will find target the pre-v6 API with different event handling.
Patterns
Bind data to elements with a joindata-join
import * as d3 from 'd3';
d3.select('#chart')
.selectAll('circle')
.data(points)
.join('circle')
.attr('cx', (d) => d.x)
.attr('cy', (d) => d.y)
.attr('r', 4);join() handles enter, update, and exit in one call; if you are copying old enter().append() tutorials you are writing 2017 D3.
Create scales and draw axesscales-axes
const x = d3.scaleLinear().domain([0, 100]).range([40, 600]);
const y = d3.scaleLinear()
.domain([0, d3.max(data, (d) => d.value)])
.range([360, 20]);
svg.append('g')
.attr('transform', 'translate(0,360)')
.call(d3.axisBottom(x));
svg.append('g')
.attr('transform', 'translate(40,0)')
.call(d3.axisLeft(y));The y range runs high-to-low because SVG pixel 0 is the top; forgetting that inverts every chart.
Draw a line from dataline-chart
const line = d3.line()
.x((d) => x(d.date))
.y((d) => y(d.value));
svg.append('path')
.datum(data)
.attr('fill', 'none')
.attr('stroke', 'steelblue')
.attr('stroke-width', 1.5)
.attr('d', line);Use datum (singular) for a single path over the whole array; fill defaults to black, so set fill none or your line becomes a blob.
Draw bars with a band scalebar-chart
const x = d3.scaleBand()
.domain(data.map((d) => d.name))
.range([40, 600])
.padding(0.1);
svg.selectAll('rect')
.data(data)
.join('rect')
.attr('x', (d) => x(d.name))
.attr('y', (d) => y(d.value))
.attr('width', x.bandwidth())
.attr('height', (d) => y(0) - y(d.value));Compute height as y(0) minus y(value), not the raw value, or bars are wrong the moment the y domain is not [0, max].
Load and type a CSV fileload-csv
const data = await d3.csv('data.csv', d3.autoType);
// without autoType every column is a string:
const manual = await d3.csv('data.csv', (d) => ({
date: new Date(d.date),
value: +d.value,
}));d3.csv returns strings for every column unless you pass d3.autoType or coerce in a row function; math on strings fails silently via concatenation.
Animate attribute changestransitions
svg.selectAll('rect')
.transition()
.duration(750)
.ease(d3.easeCubicOut)
.attr('y', (d) => y(d.value))
.attr('height', (d) => y(0) - y(d.value));A new transition on the same element interrupts the running one by default, which is usually what you want but surprises people mid-animation.
Add zoom and pan to an SVGzoom-pan
const zoom = d3.zoom()
.scaleExtent([1, 8])
.on('zoom', (event) => {
g.attr('transform', event.transform);
});
svg.call(zoom);Since v6 the handler receives the event as the first argument; the old d3.event global is gone, which breaks most pre-2020 snippets.
Run a force-directed network layoutforce-graph
const simulation = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links).id((d) => d.id))
.force('charge', d3.forceManyBody().strength(-200))
.force('center', d3.forceCenter(width / 2, height / 2))
.on('tick', () => {
link.attr('x1', (d) => d.source.x).attr('y1', (d) => d.source.y)
.attr('x2', (d) => d.target.x).attr('y2', (d) => d.target.y);
node.attr('cx', (d) => d.x).attr('cy', (d) => d.y);
});The simulation mutates your nodes and links arrays in place (adding x, y, vx, vy), so pass copies if you need the originals untouched.
Plot dates on a time scaletime-axis
const x = d3.scaleTime()
.domain(d3.extent(data, (d) => d.date))
.range([40, 600]);
svg.append('g')
.attr('transform', 'translate(0,360)')
.call(d3.axisBottom(x).ticks(6).tickFormat(d3.timeFormat('%b %Y')));scaleTime needs real Date objects in the domain; ISO strings straight from JSON will not work until you parse them.
Map data to colorscolor-scale
// categorical
const color = d3.scaleOrdinal(d3.schemeCategory10);
circle.attr('fill', (d) => color(d.group));
// continuous
const heat = d3.scaleSequential(d3.interpolateViridis)
.domain([0, d3.max(data, (d) => d.value)]);scaleOrdinal assigns colors in first-seen order, so the same category can get different colors across charts unless you fix the domain.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| @observablehq/plot | npm | You want D3-quality output for standard chart types with a tenth of the code, from the same authors. |
| chart.js | npm | You need common canvas charts with a config-object API and no interest in low-level control. |
| echarts | npm | You want a batteries-included chart library with heavy interactivity and big-data rendering out of the box. |