D3.js Bar Charts Tutorial: Simple, Grouped & Stacked
This tutorial shows how to create three essential bar chart variants in D3.js with React: a simple (single-series) bar chart, a grouped bar chart, and a stacked bar chart. Each example below renders live and includes a copyable code block.
Simple Bar Chart
The simple bar chart is a great starting point: one categorical axis and one quantitative axis.
import React, { useEffect, useRef } from 'react'
import * as d3 from 'd3'
function SimpleBarChart() {
const containerRef = useRef(null)
useEffect(() => {
const root = d3.select(containerRef.current)
root.selectAll('*').remove()
const data = [
{ category: 'A', value: 34 },
{ category: 'B', value: 18 },
{ category: 'C', value: 51 },
{ category: 'D', value: 27 },
{ category: 'E', value: 42 }
]
const width = 640, height = 360
const margin = { top: 24, right: 24, bottom: 48, left: 56 }
const innerWidth = width - margin.left - margin.right
const innerHeight = height - margin.top - margin.bottom
const svg = root
.append('svg')
.attr('viewBox', `0 0 ${width} ${height}`)
.style('max-width', '100%')
.style('height', 'auto')
const g = svg
.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`)
const x = d3.scaleBand()
.domain(data.map(d => d.category))
.range([0, innerWidth])
.padding(0.2)
const y = d3.scaleLinear()
.domain([0, d3.max(data, d => d.value) || 0]).nice()
.range([innerHeight, 0])
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.category) || 0)
.attr('y', d => y(d.value))
.attr('width', x.bandwidth())
.attr('height', d => innerHeight - y(d.value))
.attr('fill', '#3b82f6')
}, [])
return <div ref={containerRef} />
}
Grouped Bar Chart
Grouped bars compare multiple series side-by-side within each category.
import React, { useEffect, useRef } from 'react'
import * as d3 from 'd3'
function GroupedBarChart() {
const containerRef = useRef(null)
useEffect(() => {
const seriesKeys = ['Apples', 'Bananas', 'Cherries']
const data = [
{ group: '2019', Apples: 30, Bananas: 22, Cherries: 13 },
{ group: '2020', Apples: 35, Bananas: 28, Cherries: 18 },
{ group: '2021', Apples: 28, Bananas: 26, Cherries: 20 },
{ group: '2022', Apples: 40, Bananas: 31, Cherries: 24 }
]
const width = 720, height = 380
const margin = { top: 24, right: 24, bottom: 52, left: 56 }
const innerWidth = width - margin.left - margin.right
const innerHeight = height - margin.top - margin.bottom
const root = d3.select(containerRef.current)
root.selectAll('*').remove()
const svg = root.append('svg')
.attr('viewBox', `0 0 ${width} ${height}`)
.style('max-width', '100%')
.style('height', 'auto')
const g = svg.append('g').attr('transform', `translate(${margin.left},${margin.top})`)
const x0 = d3.scaleBand().domain(data.map(d => d.group)).range([0, innerWidth]).padding(0.2)
const x1 = d3.scaleBand().domain(seriesKeys).range([0, x0.bandwidth()]).padding(0.1)
const y = d3.scaleLinear().domain([0, d3.max(data, d => d3.max(seriesKeys, k => d[k])) || 0]).nice().range([innerHeight, 0])
const color = d3.scaleOrdinal().domain(seriesKeys).range(['#3b82f6', '#10b981', '#f59e0b'])
g.append('g').attr('transform', `translate(0,${innerHeight})`).call(d3.axisBottom(x0))
g.append('g').call(d3.axisLeft(y))
const groupG = g.selectAll('g.series-group')
.data(data)
.join('g')
.attr('class', 'series-group')
.attr('transform', d => `translate(${x0(d.group)},0)`)
groupG.selectAll('rect')
.data(d => seriesKeys.map(key => ({ key, group: d.group, value: d[key] })))
.join('rect')
.attr('x', d => x1(d.key) || 0)
.attr('y', d => y(d.value))
.attr('width', x1.bandwidth())
.attr('height', d => innerHeight - y(d.value))
.attr('fill', d => color(d.key))
}, [])
return <div ref={containerRef} />
}
Stacked Bar Chart
Stacked bars show parts-of-a-whole within each category.
import React, { useEffect, useRef } from 'react'
import * as d3 from 'd3'
function StackedBarChart() {
const containerRef = useRef(null)
useEffect(() => {
const seriesKeys = ['North', 'South', 'West']
const data = [
{ quarter: 'Q1', North: 12, South: 18, West: 8 },
{ quarter: 'Q2', North: 16, South: 14, West: 12 },
{ quarter: 'Q3', North: 10, South: 20, West: 14 },
{ quarter: 'Q4', North: 18, South: 12, West: 16 }
]
const width = 720, height = 380
const margin = { top: 24, right: 24, bottom: 52, left: 56 }
const innerWidth = width - margin.left - margin.right
const innerHeight = height - margin.top - margin.bottom
const root = d3.select(containerRef.current)
root.selectAll('*').remove()
const svg = root.append('svg')
.attr('viewBox', `0 0 ${width} ${height}`)
.style('max-width', '100%')
.style('height', 'auto')
const g = svg.append('g').attr('transform', `translate(${margin.left},${margin.top})`)
const x = d3.scaleBand().domain(data.map(d => d.quarter)).range([0, innerWidth]).padding(0.2)
const y = d3.scaleLinear().domain([0, d3.max(data, d => seriesKeys.reduce((s, k) => s + d[k], 0)) || 0]).nice().range([innerHeight, 0])
const color = d3.scaleOrdinal().domain(seriesKeys).range(['#3b82f6', '#10b981', '#f59e0b'])
const stacked = d3.stack().keys(seriesKeys)(data)
g.append('g').attr('transform', `translate(0,${innerHeight})`).call(d3.axisBottom(x))
g.append('g').call(d3.axisLeft(y))
g.selectAll('g.layer')
.data(stacked)
.join('g')
.attr('class', 'layer')
.attr('fill', d => color(d.key))
.selectAll('rect')
.data(d => d)
.join('rect')
.attr('x', d => x(d.data.quarter) || 0)
.attr('y', d => y(d[1]))
.attr('height', d => y(d[0]) - y(d[1]))
.attr('width', x.bandwidth())
}, [])
return <div ref={containerRef} />
}
Tips and Best Practices
- Use scales: Map your data to pixels using
d3.scaleBand
for categories andd3.scaleLinear
for values. - Axes:
d3.axisBottom
andd3.axisLeft
save time and add consistency. - Padding: For grouped bars, use two band scales (
x0
for groups,x1
for series). - Colors: Use
d3.scaleOrdinal
for categorical series. - Accessibility: Add
<title>
to bars for quick tooltips and screen readers.