In Module 09 you learned why SVG and Canvas have different performance envelopes. Now you will put SVG to work with D3.js — a library that sits between raw Canvas drawing (Modules 01–02) and declarative Chart.js (Modules 05–08). D3 does not draw a chart for you. Instead, it provides powerful utilities that bind your data to DOM elements and let you set their attributes. You decide what to draw and how to draw it. In exchange for that extra control, you get the ability to create any visualization you can imagine.
Run: Open the HTML files directly in a browser. Each file loads D3 v7 from a CDN — no install required.
D3 stands for Data-Driven Documents. The core idea is a three-step loop:
This is the enter/update/exit pattern. When the number of data items differs from the number of DOM elements:
The modern .join() method (D3 v5+) handles all three cases in one call. You will see this in every example below.
D3 typically outputs SVG (Scalable Vector Graphics) rather than Canvas. SVG elements are real DOM nodes — you can inspect them in DevTools, style them with CSS, and attach event listeners directly. Here are the SVG elements you need for charting:
| Element | Purpose | Key Attributes |
|---|---|---|
<svg> | Container (the "canvas") | width, height, viewBox |
<rect> | Rectangle (bars) | x, y, width, height, fill |
<line> | Straight line | x1, y1, x2, y2, stroke |
<text> | Text label | x, y, text-anchor, dominant-baseline |
<circle> | Data point dot | cx, cy, r, fill |
<g> | Group (like a div) | transform="translate(x, y)" |
<path> | Arbitrary shape | d (path data string) |
SVG shares the same coordinate system as Canvas: origin at the top-left, y increases downward. The viewBox attribute lets you define a logical coordinate space independent of the element's physical pixel size:
<!-- Physical size: 600x400 CSS pixels -->
<!-- Logical coordinate space: 0,0 to 600,400 -->
<svg width="600" height="400" viewBox="0 0 600 400">
<!-- A teal rectangle at x=50, y=50, 200px wide, 100px tall -->
<rect x="50" y="50" width="200" height="100" fill="#16a085" />
<!-- A label centered at x=150, y=200 -->
<text x="150" y="200" text-anchor="middle"
font-family="sans-serif" font-size="14">Hello SVG</text>
<!-- A group translated to act like a local coordinate system -->
<g transform="translate(300, 100)">
<rect width="100" height="80" fill="#e74c3c" />
<text y="95" font-size="12">Inside the group</text>
</g>
</svg>
<g> element is essential. D3 charts use <g> with a translate() transform to implement the margin convention. Instead of adding margin offsets to every coordinate, you create a group shifted by the margin amounts and draw everything inside it using zero-based coordinates.
A D3 selection is a wrapped reference to one or more DOM elements, similar to jQuery but designed for data binding. Two core methods:
// Select the first matching element (like querySelector)
const header = d3.select('h1');
// Select ALL matching elements (like querySelectorAll)
const paragraphs = d3.selectAll('p');
// Chain modifications
d3.select('#chart')
.style('border', '1px solid #ccc')
.attr('class', 'active')
.text('Updated!');
Selections return the selection itself, so every method chains. This fluent API is how D3 code reads like a pipeline: select something, then transform it step by step.
Try the Selections Demo to see d3.select(), d3.selectAll(), and .data().join() in action with visual feedback.
Data binding is the heart of D3. You take a selection, attach an array of data, and D3 figures out which elements need to be created, updated, or removed:
const data = [120, 340, 210, 450, 175];
// Select all <rect> elements inside the SVG (initially none exist)
// Bind the data array
// .join('rect') creates a <rect> for each datum
d3.select('svg')
.selectAll('rect')
.data(data)
.join('rect')
.attr('x', (d, i) => i * 60)
.attr('y', d => 300 - d)
.attr('width', 50)
.attr('height', d => d)
.attr('fill', '#16a085');
The callback (d, i) => receives two arguments: d is the datum (the data value bound to this element) and i is the index. This is how every attribute becomes a function of the data.
In Module 02 you wrote a linearScale() function by hand. D3 provides the same thing — and much more — as first-class scale objects. A D3 scale is a function: you call it with a data value and it returns a pixel value.
// Linear scale: maps a continuous domain to a continuous range
const yScale = d3.scaleLinear()
.domain([0, 1250]) // data values: 0 to max pageviews
.range([300, 0]); // pixel values: bottom to top (inverted!)
yScale(0); // → 300 (zero maps to bottom)
yScale(1250); // → 0 (max maps to top)
yScale(625); // → 150 (midpoint maps to middle)
// Band scale: maps discrete categories to evenly-spaced bands
const xScale = d3.scaleBand()
.domain(['/home', '/about', '/products', '/contact', '/blog'])
.range([0, 500])
.padding(0.2); // 20% of each band is gap
xScale('/home'); // → 10 (start of first band)
xScale.bandwidth(); // → 80 (width of each band)
// Ordinal scale: maps categories to specific output values
const colorScale = d3.scaleOrdinal()
.domain(['/home', '/about', '/products', '/contact', '/blog'])
.range(['#16a085', '#2980b9', '#8e44ad', '#e67e22', '#e74c3c']);
The critical insight: D3 scales are just functions. You configure them with .domain() and .range(), then call them like any function. This is identical to the hand-rolled linearScale() from Module 02, but D3 adds features like .nice() (rounds domain to friendly values), .ticks() (generates tick values), and .invert() (pixel back to data value).
Explore the Scales Demo to interactively compare the manual scale from Module 02 with D3's scaleLinear() and scaleBand().
| Scale Type | Input | Output | Use Case |
|---|---|---|---|
scaleLinear() | Continuous number | Continuous number | Y-axis (pageviews → pixels) |
scaleBand() | Discrete category | Band position + width | X-axis for bar charts |
scaleOrdinal() | Discrete category | Any discrete value | Mapping categories to colors |
scaleTime() | Date object | Continuous number | Time-series x-axis |
scaleLog() | Continuous number | Continuous number | Data with huge range |
Drawing axis lines, tick marks, and labels by hand (as you did in Module 01) is tedious. D3 provides axis generators that do it all automatically:
// Create axis generators from your scales
const xAxis = d3.axisBottom(xScale); // ticks below the line
const yAxis = d3.axisLeft(yScale); // ticks to the left
// Append a <g> element and call the axis generator on it
svg.append('g')
.attr('transform', `translate(0, ${chartHeight})`)
.call(xAxis);
svg.append('g')
.call(yAxis);
The .call() method passes the selection to the axis generator, which populates it with <line>, <text>, and <path> elements forming the complete axis. The axis reads the scale's domain and automatically generates appropriate tick values and labels.
Just as in Module 01, D3 charts use a margin convention. But instead of manually adding offsets to every coordinate, D3 uses a <g> element with a translate() transform:
const margin = { top: 20, right: 20, bottom: 40, left: 50 };
const width = 600 - margin.left - margin.right; // chart area width
const height = 400 - margin.top - margin.bottom; // chart area height
// Create the SVG at full size
const svg = d3.select('#chart')
.append('svg')
.attr('width', 600)
.attr('height', 400);
// Create a group shifted by the margins — everything draws inside this
const g = svg.append('g')
.attr('transform', `translate(${margin.left}, ${margin.top})`);
// Now coordinates are relative to the chart area:
// (0, 0) = top-left of the chart area
// (width, height) = bottom-right of the chart area
Here is the complete pattern for a D3 bar chart, using the same pageview data from Module 01. See the full working version in d3-bar-chart.html.
const data = [
{ page: '/home', views: 1250 },
{ page: '/about', views: 430 },
{ page: '/products', views: 890 },
{ page: '/contact', views: 320 },
{ page: '/blog', views: 675 }
];
// 1. Scales
const xScale = d3.scaleBand()
.domain(data.map(d => d.page))
.range([0, width])
.padding(0.2);
const yScale = d3.scaleLinear()
.domain([0, d3.max(data, d => d.views)])
.nice()
.range([height, 0]);
const colorScale = d3.scaleOrdinal()
.domain(data.map(d => d.page))
.range(d3.schemeTableau10);
// 2. Axes
g.append('g')
.attr('transform', `translate(0, ${height})`)
.call(d3.axisBottom(xScale));
g.append('g')
.call(d3.axisLeft(yScale));
// 3. Bars — the data binding pattern
g.selectAll('rect')
.data(data)
.join('rect')
.attr('x', d => xScale(d.page))
.attr('y', d => yScale(d.views))
.attr('width', xScale.bandwidth())
.attr('height', d => height - yScale(d.views))
.attr('fill', d => colorScale(d.page));
Count the lines: scales, axes, and data-bound bars in about 30 lines of code. The Canvas version from Module 01 needed roughly 80 lines to achieve the same result, and the Chart.js version from Module 05 was about 20 lines but with far less control over the output.
By now you have seen the same bar chart built three ways. Here is how they compare:
| Aspect | Canvas (Module 01) | Chart.js (Module 05) | D3 (This Module) |
|---|---|---|---|
| Paradigm | Imperative pixel drawing | Declarative config object | Imperative data binding |
| Output | Bitmap (pixels) | Bitmap (Canvas internally) | SVG (DOM elements) |
| Scale functions | Hand-rolled | Automatic (hidden) | Explicit (d3.scaleLinear) |
| Axes | Hand-drawn with loops | Automatic from config | d3.axisBottom() / axisLeft() |
| CSS styling | Not possible | Not possible | Full CSS support |
| Interactivity | Manual hit-testing | Built-in tooltips | Native DOM events per element |
| Lines of code (bar chart) | ~80 | ~20 | ~30 |
| Custom visualization | Anything (low-level) | Limited to chart types | Anything (high-level primitives) |
| Performance limit | ~100K data points | ~100K (same Canvas engine) | ~5K–10K SVG elements |
| Best for | Performance (many points) | Standard dashboard charts | Custom, interactive, unique visualizations |
D3 v7 is modular. The full bundle (d3) includes everything, but in production you can import only the modules you need:
<!-- Full bundle from CDN (simplest — what we use in this tutorial) -->
<script src="https://cdn.jsdelivr.net/npm/d3@7"></script>
<!-- Or import specific modules (tree-shakeable in bundled projects) -->
<script type="module">
import { select, selectAll } from 'd3-selection';
import { scaleLinear, scaleBand } from 'd3-scale';
import { axisBottom, axisLeft } from 'd3-axis';
import { max } from 'd3-array';
</script>
The modules we used in this tutorial:
d3-selection — select(), selectAll(), .data(), .join()d3-scale — scaleLinear(), scaleBand(), scaleOrdinal()d3-axis — axisBottom(), axisLeft()d3-array — max(), min(), extent()selectAll('rect').data(data).join('rect') works even when there are zero <rect> elements — the enter selection creates them. This is counterintuitive but correct.<g> transform: If your chart draws at the very top-left of the SVG, you forgot to create the margin group with translate().chartHeight - yScale(value), not yScale(value). Since y=0 is the top, yScale(value) gives the top of the bar and the height extends downward to the baseline.scaleBand and scaleLinear: Use scaleBand() for categorical axes (page names, months) and scaleLinear() for numeric axes (pageview counts)..call(axisBottom(xScale)) on a <g> element, not on the SVG directly.<rect>, <text>, <line>, <g>) are the building blocks; they live in the DOM and support CSSd3.scaleLinear() maps continuous data to continuous pixels; d3.scaleBand() maps categories to bandsd3.axisBottom() and d3.axisLeft() automatically generate tick marks and labels from a scale<g transform="translate()"> to create a zero-based chart coordinate systemscaleBand() on the y-axis, scaleLinear() on the x-axis.<text> element above each bar showing the exact pageview count..data(newData).join('rect') again. Watch D3 update the bars.