D3.js Interactive Dashboards Development
D3.js is not a charting library. It's a tool for binding data to DOM and managing SVG, Canvas, and HTML. Most off-the-shelf libraries (Chart.js, Recharts, Highcharts) are built on top of D3 or use similar concepts, but offer fixed chart types. D3 is a level below: for visualizing non-standard things or gaining full control over interactivity.
When to Use D3 vs Pre-built Libraries
Recharts or Chart.js is the right choice for standard tasks: line trends, bar charts, pies. Install, three props, done.
D3 is justified when:
- Custom projections needed (maps, radar charts with non-linear axes)
- Interactivity requires precise state management (brush selection, synchronized zoom)
- Animations tied to data through transitions
- Multiple linked visualizations with shared state (brushing & linking)
Project Structure
src/
├── visualizations/
│ ├── core/
│ │ ├── scales.ts # reusable d3 scales
│ │ ├── axes.ts # formatted axes
│ │ └── tooltip.ts # unified tooltip manager
│ ├── charts/
│ │ ├── LineChart.tsx
│ │ ├── BarChart.tsx
│ │ └── ScatterPlot.tsx
│ └── dashboard/
│ ├── Dashboard.tsx
│ └── useDashboardState.ts
Basic Pattern: D3 Inside React
Two approaches. First—D3 fully manages DOM, React only renders container:
import { useEffect, useRef } from 'react';
import * as d3 from 'd3';
interface LineChartProps {
data: { date: Date; value: number }[];
width?: number;
height?: number;
}
export function LineChart({ data, width = 800, height = 400 }: LineChartProps) {
const svgRef = useRef<SVGSVGElement>(null);
const margin = { top: 20, right: 30, bottom: 40, left: 50 };
const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;
useEffect(() => {
if (!svgRef.current || !data.length) return;
const svg = d3.select(svgRef.current);
svg.selectAll('*').remove(); // clear before redraw
const g = svg
.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`);
// Scales
const xScale = d3
.scaleTime()
.domain(d3.extent(data, d => d.date) as [Date, Date])
.range([0, innerWidth]);
const yScale = d3
.scaleLinear()
.domain([0, d3.max(data, d => d.value) ?? 0])
.nice()
.range([innerHeight, 0]);
// Line generator
const line = d3
.line<{ date: Date; value: number }>()
.x(d => xScale(d.date))
.y(d => yScale(d.value))
.curve(d3.curveMonotoneX);
// Axes
g.append('g')
.attr('transform', `translate(0,${innerHeight})`)
.call(d3.axisBottom(xScale).ticks(6).tickFormat(d3.timeFormat('%d %b')));
g.append('g').call(
d3.axisLeft(yScale).ticks(5).tickFormat(d => d3.format(',.0f')(+d))
);
// Path
g.append('path')
.datum(data)
.attr('fill', 'none')
.attr('stroke', '#3b82f6')
.attr('stroke-width', 2)
.attr('d', line);
// Dots with tooltip
const tooltip = d3.select('#tooltip');
g.selectAll('.dot')
.data(data)
.join('circle')
.attr('class', 'dot')
.attr('cx', d => xScale(d.date))
.attr('cy', d => yScale(d.value))
.attr('r', 4)
.attr('fill', '#3b82f6')
.on('mouseover', (event, d) => {
tooltip
.style('display', 'block')
.style('left', `${event.pageX + 12}px`)
.style('top', `${event.pageY - 28}px`)
.html(`<strong>${d3.timeFormat('%d %b %Y')(d.date)}</strong><br/>${d3.format(',.0f')(d.value)}`);
})
.on('mouseout', () => {
tooltip.style('display', 'none');
});
}, [data, width, height]);
return (
<>
<svg ref={svgRef} width={width} height={height} />
<div
id="tooltip"
style={{
position: 'absolute',
display: 'none',
background: 'rgba(0,0,0,0.75)',
color: '#fff',
padding: '6px 10px',
borderRadius: 4,
fontSize: 12,
pointerEvents: 'none',
}}
/>
</>
);
}
Second approach—React renders SVG elements, D3 used only for computations:
// Only d3-scale, d3-array, d3-shape—no DOM manipulation
import { scaleLinear, scaleTime } from 'd3-scale';
import { line, curveMonotoneX } from 'd3-shape';
import { extent, max } from 'd3-array';
export function LineChartReact({ data, width = 800, height = 400 }) {
const margin = { top: 20, right: 30, bottom: 40, left: 50 };
const iw = width - margin.left - margin.right;
const ih = height - margin.top - margin.bottom;
const xScale = scaleTime()
.domain(extent(data, d => d.date) as [Date, Date])
.range([0, iw]);
const yScale = scaleLinear()
.domain([0, max(data, d => d.value) ?? 0])
.nice()
.range([ih, 0]);
const linePath = line<typeof data[0]>()
.x(d => xScale(d.date))
.y(d => yScale(d.value))
.curve(curveMonotoneX)(data);
return (
<svg width={width} height={height}>
<g transform={`translate(${margin.left},${margin.top})`}>
<path d={linePath ?? ''} fill="none" stroke="#3b82f6" strokeWidth={2} />
{data.map((d, i) => (
<circle key={i} cx={xScale(d.date)} cy={yScale(d.value)} r={4} fill="#3b82f6" />
))}
</g>
</svg>
);
}
Second approach integrates better with React DevTools, easier to test, but first gives more control over transitions and complex interactions.
Zoom and Brush
The two most sought interactions in analytics dashboards.
// Zoom with X-axis synchronization
function addZoom(svg: d3.Selection<SVGSVGElement, unknown, null, undefined>, xScale: d3.ScaleTime<number, number>, onZoom: (newScale: d3.ScaleTime<number, number>) => void) {
const zoom = d3.zoom<SVGSVGElement, unknown>()
.scaleExtent([1, 20])
.translateExtent([[0, 0], [innerWidth, innerHeight]])
.extent([[0, 0], [innerWidth, innerHeight]])
.on('zoom', (event: d3.D3ZoomEvent<SVGSVGElement, unknown>) => {
const newXScale = event.transform.rescaleX(xScale);
onZoom(newXScale);
});
svg.call(zoom);
}
// Brush for range selection
function addBrush(g: d3.Selection<SVGGElement, unknown, null, undefined>, onBrush: (range: [Date, Date] | null) => void) {
const brush = d3.brushX()
.extent([[0, 0], [innerWidth, innerHeight]])
.on('end', (event) => {
if (!event.selection) {
onBrush(null);
return;
}
const [x0, x1] = event.selection as [number, number];
onBrush([xScale.invert(x0), xScale.invert(x1)]);
});
g.append('g').attr('class', 'brush').call(brush);
}
Performance with Large Datasets
D3 + SVG starts lagging after ~5000 points. Solutions:
Canvas instead of SVG for scatter plots with thousands of points:
useEffect(() => {
const canvas = canvasRef.current!;
const ctx = canvas.getContext('2d')!;
ctx.clearRect(0, 0, width, height);
data.forEach(d => {
ctx.beginPath();
ctx.arc(xScale(d.x), yScale(d.y), 3, 0, 2 * Math.PI);
ctx.fillStyle = colorScale(d.category);
ctx.fill();
});
}, [data]);
Decimation—data thinning before render. Chart.js has a built-in plugin; for D3 implement manually via LTTB (Largest Triangle Three Buckets):
// Simplified LTTB
function lttbDecimate(data: Point[], threshold: number): Point[] {
if (data.length <= threshold) return data;
const sampled: Point[] = [data[0]];
const bucketSize = (data.length - 2) / (threshold - 2);
for (let i = 0; i < threshold - 2; i++) {
const rangeStart = Math.floor((i + 1) * bucketSize) + 1;
const rangeEnd = Math.min(Math.floor((i + 2) * bucketSize) + 1, data.length);
const avgX = data.slice(rangeEnd, rangeEnd).reduce((s, d) => s + d.x, 0);
const avgY = data.slice(rangeEnd, rangeEnd).reduce((s, d) => s + d.y, 0);
let maxArea = -1;
let maxPoint = data[rangeStart];
for (let j = rangeStart; j < rangeEnd; j++) {
const area = Math.abs(
(sampled[sampled.length - 1].x - avgX) * (data[j].y - sampled[sampled.length - 1].y) -
(sampled[sampled.length - 1].x - data[j].x) * (avgY - sampled[sampled.length - 1].y)
);
if (area > maxArea) { maxArea = area; maxPoint = data[j]; }
}
sampled.push(maxPoint);
}
sampled.push(data[data.length - 1]);
return sampled;
}
Syncing Multiple Charts
Classic pattern for analytics dashboards—brushing & linking: range selection on one chart filters data on all others.
function Dashboard() {
const [brushRange, setBrushRange] = useState<[Date, Date] | null>(null);
const filteredData = useMemo(() => {
if (!brushRange) return fullData;
return fullData.filter(d => d.date >= brushRange[0] && d.date <= brushRange[1]);
}, [brushRange]);
return (
<div className="grid grid-cols-2 gap-4">
<TimelineChart data={fullData} onBrush={setBrushRange} />
<BarChart data={filteredData} />
<ScatterPlot data={filteredData} />
<MetricsTable data={filteredData} />
</div>
);
}
Export SVG to PNG/PDF
async function exportChart(svgElement: SVGSVGElement, filename: string) {
const serializer = new XMLSerializer();
const svgStr = serializer.serializeToString(svgElement);
const blob = new Blob([svgStr], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = svgElement.viewBox.baseVal.width * 2; // retina
canvas.height = svgElement.viewBox.baseVal.height * 2;
const ctx = canvas.getContext('2d')!;
ctx.scale(2, 2);
ctx.drawImage(img, 0, 0);
URL.revokeObjectURL(url);
canvas.toBlob(blob => {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob!);
a.download = `${filename}.png`;
a.click();
});
};
img.src = url;
}
Responsiveness
Dashboard should work on various screens. Pattern with ResizeObserver:
function useChartDimensions(containerRef: RefObject<HTMLDivElement>) {
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
useEffect(() => {
const observer = new ResizeObserver(entries => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
setDimensions({ width, height });
}
});
if (containerRef.current) observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);
return dimensions;
}
function ResponsiveLineChart({ data }) {
const containerRef = useRef<HTMLDivElement>(null);
const { width, height } = useChartDimensions(containerRef);
return (
<div ref={containerRef} style={{ width: '100%', height: 300 }}>
{width > 0 && <LineChart data={data} width={width} height={height} />}
</div>
);
}
Timeline
Single custom interactive chart (tooltip, zoom)—2–4 days. Full analytics dashboard with 4–6 linked visualizations, filters, syncing, and export—3–5 weeks. Complex maps (geo visualization, projections)—separate estimate after data review.







