Building Charts with D3.js: Scales, Axes, Bar and Line Charts, Transitions and React
Key takeaways
D3.js gives you the building blocks — SVG, scales, axes, layouts — to build any chart imaginable. This guide covers the core concepts with complete working examples: bar charts, line charts, scatter plots, and interactive transitions.
D3.js Core Concepts
D3 (Data-Driven Documents) works by:
- Selecting DOM elements
- Binding data to them
- Transforming elements based on data (enter/update/exit)
The mental model that trips up people coming from React or Vue is that D3 doesn’t have its own virtual DOM or component tree — it operates directly on real DOM/SVG nodes through method chaining, and “binding data” literally means attaching each data point to a specific element as hidden state (__data__ under the hood) that later calls can read back. This is also why D3 and React can clash if you’re not careful: both frameworks assume they own the DOM nodes they touch, so mixing them (covered in the React integration section below) requires deciding explicitly which one is in charge of a given subtree.
Data → Scale → SVG Element
[10, 20, 50] → scaleLinear → rect height="10" height="20" height="50"
Setup
npm install d3
npm install --save-dev @types/d3 # TypeScript types
<!-- Or CDN for quick prototyping -->
<script src="https://d3js.org/d3.v7.min.js"></script>
SVG Basics
D3 draws into SVG rather than <canvas> for most use cases, which matters practically: SVG elements are real, individually-styleable, individually-clickable DOM nodes (so CSS, event listeners, and even browser dev tools inspection work on each bar or point directly), at the cost of more DOM nodes than a canvas-rendered chart would need — a distinction worth knowing about if you’re ever rendering thousands of data points and start seeing DOM-related performance problems. Understanding SVG coordinates:
(0,0)———————————————→ x
│ SVG coordinate system
│ origin is top-left
│ y increases downward
↓
y
The margin object and the inner <g> translation is the standard D3 idiom (sometimes called “the margin convention”) for reserving space around a chart’s plotting area for axes and labels — rather than hardcoding pixel offsets into every element’s position, everything drawn inside chart is positioned relative to (0,0) at the plot’s actual top-left corner, and the outer margin shift is handled once via the transform attribute.
// Create SVG container
const width = 800
const height = 400
const margin = { top: 20, right: 30, bottom: 40, left: 50 }
const svg = d3.select('#chart')
.append('svg')
.attr('width', width)
.attr('height', height)
// Inner chart area (accounting for margins)
const chart = svg.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`)
const innerWidth = width - margin.left - margin.right // 720
const innerHeight = height - margin.top - margin.bottom // 340
Scales — The Core of D3
Scales map data values to visual values (pixels, colors). This one abstraction is what separates D3 from just manually computing pixel positions with arithmetic: a scale is a pure function you configure once with a domain (the range of your actual data) and a range (the range of pixels/colors you want to map onto), and afterward you just call it like yScale(dataValue) wherever you need the corresponding visual value — if the data changes, updating .domain() once propagates correctly everywhere the scale is used, instead of hunting down every hardcoded calculation in the chart.
// Linear scale: numbers → pixels
const yScale = d3.scaleLinear()
.domain([0, d3.max(data, d => d.value)]) // data range
.range([innerHeight, 0]) // pixel range (inverted: 0 is bottom)
.nice() // round to nice values
yScale(0) // → innerHeight (bottom)
yScale(100) // → 0 (top)
yScale(50) // → innerHeight / 2
// The range [innerHeight, 0] is deliberately inverted, not a typo: SVG's
// y-axis increases downward, so mapping the data minimum to the larger
// pixel value (innerHeight, visually the bottom) and the data maximum to 0
// (visually the top) is what makes bigger values draw higher on screen.
// Band scale: categories → bar positions
const xScale = d3.scaleBand()
.domain(data.map(d => d.name)) // ['Jan', 'Feb', 'Mar', ...]
.range([0, innerWidth])
.padding(0.2) // 20% padding between bars
xScale('Jan') // → x position of Jan bar
xScale.bandwidth() // → width of each bar
// Time scale — like scaleLinear, but the domain is Date objects and D3
// automatically produces sensible tick labels (months, days, hours) based
// on the span of dates rather than raw numeric intervals
const timeScale = d3.scaleTime()
.domain([new Date('2024-01-01'), new Date('2024-12-31')])
.range([0, innerWidth])
// Color scale
const colorScale = d3.scaleOrdinal()
.domain(['A', 'B', 'C'])
.range(['#ff6b6b', '#4ecdc4', '#45b7d1'])
Bar Chart
This example ties together the margin convention and scales from the previous two sections into a complete, working chart. Note the * 1.1 padding factor on the y-scale’s domain max — without it, the tallest bar would touch the very top edge of the chart with no visual breathing room, a small but common polish detail. The bar heights use innerHeight - y(d.sales) rather than just y(d.sales) because SVG <rect> height grows downward from its y attribute — you need the distance from the bar’s top (y(d.sales)) down to the chart’s baseline (innerHeight), not the y-coordinate itself.
const data = [
{ month: 'Jan', sales: 4200 },
{ month: 'Feb', sales: 3800 },
{ month: 'Mar', sales: 5100 },
{ month: 'Apr', sales: 4700 },
{ month: 'May', sales: 5300 },
{ month: 'Jun', sales: 6200 },
]
const width = 600, height = 400
const margin = { top: 20, right: 20, bottom: 40, left: 60 }
const innerWidth = width - margin.left - margin.right
const innerHeight = height - margin.top - margin.bottom
const svg = d3.select('#bar-chart')
.append('svg')
.attr('width', width)
.attr('height', height)
const g = svg.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`)
// Scales
const x = d3.scaleBand()
.domain(data.map(d => d.month))
.range([0, innerWidth])
.padding(0.3)
const y = d3.scaleLinear()
.domain([0, d3.max(data, d => d.sales) * 1.1])
.range([innerHeight, 0])
// Axes
g.append('g')
.attr('transform', `translate(0,${innerHeight})`)
.call(d3.axisBottom(x))
g.append('g')
.call(d3.axisLeft(y).tickFormat(d => `$${d3.format(',')(d)}`))
// Bars
g.selectAll('rect')
.data(data)
.join('rect')
.attr('x', d => x(d.month))
.attr('y', d => y(d.sales))
.attr('width', x.bandwidth())
.attr('height', d => innerHeight - y(d.sales))
.attr('fill', '#4ecdc4')
.attr('rx', 4) // rounded corners
Line Chart
d3.line() and d3.area() don’t draw anything themselves — they’re path generators: functions that take an array of data and return an SVG path d attribute string encoding the line’s geometry, computed via the x/y accessor functions you configure. d3.extent() is a small but handy utility that returns [min, max] in a single pass, which is exactly what a time scale’s domain needs and saves writing two separate d3.min/d3.max calls. The curve option (curveMonotoneX here) controls how D3 interpolates between your actual data points — without it, D3 defaults to straight line segments (curveLinear), which looks noticeably more jagged for smoothly-varying data; curveMonotoneX specifically guarantees the interpolated curve never overshoots (dips below or above) the actual data values between points, which plain cubic splines can do.
const timeData = [
{ date: new Date('2024-01-01'), value: 100 },
{ date: new Date('2024-02-01'), value: 120 },
{ date: new Date('2024-03-01'), value: 115 },
{ date: new Date('2024-04-01'), value: 140 },
{ date: new Date('2024-05-01'), value: 135 },
{ date: new Date('2024-06-01'), value: 160 },
]
// Scales
const x = d3.scaleTime()
.domain(d3.extent(timeData, d => d.date))
.range([0, innerWidth])
const y = d3.scaleLinear()
.domain([0, d3.max(timeData, d => d.value) * 1.1])
.range([innerHeight, 0])
// Line generator
const line = d3.line()
.x(d => x(d.date))
.y(d => y(d.value))
.curve(d3.curveMonotoneX) // smooth curve
// Area (gradient fill under line)
const area = d3.area()
.x(d => x(d.date))
.y0(innerHeight)
.y1(d => y(d.value))
.curve(d3.curveMonotoneX)
// Draw area (fill under curve)
g.append('path')
.datum(timeData)
.attr('fill', 'rgba(78, 205, 196, 0.2)')
.attr('d', area)
// Draw line
g.append('path')
.datum(timeData)
.attr('fill', 'none')
.attr('stroke', '#4ecdc4')
.attr('stroke-width', 2.5)
.attr('d', line)
// Data points
g.selectAll('circle')
.data(timeData)
.join('circle')
.attr('cx', d => x(d.date))
.attr('cy', d => y(d.value))
.attr('r', 5)
.attr('fill', '#4ecdc4')
.attr('stroke', 'white')
.attr('stroke-width', 2)
Tooltips
SVG has no native tooltip element, so this pattern — a single, reused, absolutely-positioned <div> appended to <body> and toggled via opacity — is the standard way to fake one. Creating it once outside the data-join (rather than one tooltip <div> per data point) matters: you only need one visible tooltip at a time, and repositioning/showing the same element on mouseover avoids creating and destroying DOM nodes on every hover, which would be wasteful and could introduce flicker. pointer-events: none is easy to miss but important — without it, the tooltip itself would intercept mouse events meant for the chart underneath it, causing mouseout to fire prematurely as the cursor crosses over the tooltip.
// Create tooltip element
const tooltip = d3.select('body')
.append('div')
.attr('class', 'tooltip')
.style('position', 'absolute')
.style('background', 'rgba(0,0,0,0.8)')
.style('color', 'white')
.style('padding', '8px 12px')
.style('border-radius', '4px')
.style('font-size', '13px')
.style('pointer-events', 'none')
.style('opacity', 0)
// Add tooltip to bars
g.selectAll('rect')
.data(data)
.join('rect')
// ... attrs ...
.on('mouseover', (event, d) => {
tooltip
.style('opacity', 1)
.html(`
<strong>${d.month}</strong><br/>
Sales: $${d3.format(',')(d.sales)}
`)
})
.on('mousemove', (event) => {
tooltip
.style('left', `${event.pageX + 12}px`)
.style('top', `${event.pageY - 28}px`)
})
.on('mouseout', () => {
tooltip.style('opacity', 0)
})
Transitions (Animation)
.transition() doesn’t animate arbitrarily between two states you specify — it interpolates between the element’s current attribute values (whatever was set before .transition() was called) and the new ones set afterward, over the given duration, using D3’s built-in interpolators (numeric attributes like height interpolate linearly by default; colors interpolate through color space). This is why the “start from bottom” setup (y: innerHeight, height: 0) has to happen before .transition() in the chain — those become the starting values the animation interpolates away from. The updateChart function demonstrates the full enter/update/exit pattern that gives D3’s data join its name: .join(enter, update, exit) splits the selection into new elements needing to be created, existing elements needing to be updated, and stale elements (no longer backed by data) needing to be removed — each gets its own handling, which is what makes it possible to animate additions and removals differently from the update transition.
// Animate bars on load
g.selectAll('rect')
.data(data)
.join('rect')
.attr('x', d => x(d.month))
.attr('width', x.bandwidth())
.attr('y', innerHeight) // start from bottom
.attr('height', 0) // start with height 0
.attr('fill', '#4ecdc4')
.transition() // start transition
.duration(800)
.delay((d, i) => i * 100) // stagger bars
.ease(d3.easeCubicOut)
.attr('y', d => y(d.sales))
.attr('height', d => innerHeight - y(d.sales))
// Update chart with new data
function updateChart(newData) {
const y = d3.scaleLinear()
.domain([0, d3.max(newData, d => d.value)])
.range([innerHeight, 0])
g.selectAll('rect')
.data(newData)
.join(
enter => enter.append('rect') // new elements
.attr('fill', '#4ecdc4')
.attr('y', innerHeight)
.attr('height', 0),
update => update, // existing elements
exit => exit // removed elements
.transition().duration(300)
.attr('height', 0)
.attr('y', innerHeight)
.remove()
)
.transition().duration(500)
.attr('x', d => x(d.name))
.attr('width', x.bandwidth())
.attr('y', d => y(d.value))
.attr('height', d => innerHeight - y(d.value))
}
Scatter Plot with Brushing
d3.brush() adds a draggable rectangular selection region directly to an SVG chart, and firing on the 'brush end' event (both intermediate drag updates and the final release, combined into one handler with a space-separated event string) lets you highlight points as the user drags rather than only after they let go. The !selection check matters because clicking without dragging clears the brush and fires the event with selection as null — without that guard, the code inside would throw trying to destructure null into [[x0, y0], [x1, y1]]. This is a good example of D3’s philosophy differing from a typical UI library: brushing, zooming, and dragging are all provided as low-level behaviors you wire up to your own SVG elements and data, rather than a pre-built “selectable scatter plot” component.
const scatterData = d3.range(100).map(() => ({
x: Math.random() * 100,
y: Math.random() * 100,
category: ['A', 'B', 'C'][Math.floor(Math.random() * 3)],
}))
const color = d3.scaleOrdinal()
.domain(['A', 'B', 'C'])
.range(['#ff6b6b', '#4ecdc4', '#45b7d1'])
const xScale = d3.scaleLinear().domain([0, 100]).range([0, innerWidth])
const yScale = d3.scaleLinear().domain([0, 100]).range([innerHeight, 0])
// Draw points
const dots = g.selectAll('circle')
.data(scatterData)
.join('circle')
.attr('cx', d => xScale(d.x))
.attr('cy', d => yScale(d.y))
.attr('r', 6)
.attr('fill', d => color(d.category))
.attr('opacity', 0.7)
// Add brush for selection
const brush = d3.brush()
.extent([[0, 0], [innerWidth, innerHeight]])
.on('brush end', ({ selection }) => {
if (!selection) {
dots.attr('opacity', 0.7)
return
}
const [[x0, y0], [x1, y1]] = selection
dots.attr('opacity', d => {
const cx = xScale(d.x), cy = yScale(d.y)
return x0 <= cx && cx <= x1 && y0 <= cy && cy <= y1 ? 1 : 0.2
})
})
g.append('g').call(brush)
React + D3 Integration
import { useEffect, useRef } from 'react'
import * as d3 from 'd3'
interface BarChartProps {
data: { name: string; value: number }[]
width?: number
height?: number
}
function BarChart({ data, width = 500, height = 300 }: BarChartProps) {
const svgRef = useRef<SVGSVGElement>(null)
useEffect(() => {
if (!svgRef.current || !data.length) return
const margin = { top: 20, right: 20, bottom: 40, left: 50 }
const innerWidth = width - margin.left - margin.right
const innerHeight = height - margin.top - margin.bottom
// Clear previous render
d3.select(svgRef.current).selectAll('*').remove()
const svg = d3.select(svgRef.current)
const g = svg.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`)
const x = d3.scaleBand()
.domain(data.map(d => d.name))
.range([0, innerWidth])
.padding(0.3)
const y = d3.scaleLinear()
.domain([0, d3.max(data, d => d.value)!])
.range([innerHeight, 0])
.nice()
g.append('g')
.attr('transform', `translate(0,${innerHeight})`)
.call(d3.axisBottom(x))
g.append('g')
.call(d3.axisLeft(y))
g.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 => innerHeight - y(d.value))
.attr('fill', '#4ecdc4')
.attr('rx', 3)
}, [data, width, height])
return <svg ref={svgRef} width={width} height={height} />
}
// Usage
const data = [
{ name: 'Jan', value: 4200 },
{ name: 'Feb', value: 3800 },
{ name: 'Mar', value: 5100 },
]
<BarChart data={data} width={600} height={350} />
D3 vs Chart Libraries
| D3.js | Chart.js | Recharts | Observable Plot | |
|---|---|---|---|---|
| Flexibility | Unlimited | Low | Medium | Medium |
| Learning curve | Steep | Gentle | Medium | Gentle |
| Bundle size | Modular (~50KB) | 200KB | 300KB+ | ~60KB |
| Custom charts | Yes | Limited | Limited | Limited |
| React integration | Manual | Plugin | Native | Plugin |
| Best for | Custom, complex | Common charts | React apps | Quick exploration |
Frequently Asked Questions (FAQ)
Q. Why does my React + D3 chart draw duplicate axes or bars when the data changes?
A. Every time the useEffect re-runs, D3 appends new <g> groups and axes to the same SVG node, so the old ones stay on screen underneath the new ones. The example in this article avoids that by calling d3.select(svgRef.current).selectAll('*').remove() before redrawing. The alternative is to let React own the SVG elements and use D3 only for scales and math, which sidesteps the conflict between two libraries mutating the same DOM.
Related Articles
- React Hooks Pitfalls: Stale Closures, Effect Loops, Fetch Races and Useless Memoization
- What TypeScript 5 Changed