Why D3.js with TypeScript — and Why This Combination Works
Two years ago, I inherited a project that displayed raw CSV exports in a plain HTML table. The client wanted to spot seasonal trends, compare regional patterns, and drill down interactively — all inside the browser. Standard chart libraries looked fine until we needed custom interactions. That’s when I switched to D3.js paired with TypeScript, and every production dashboard I’ve built since has used the same stack.
D3 (Data-Driven Documents) gives you direct control over every SVG element on the page. TypeScript adds type safety that keeps large D3 codebases maintainable — you catch data shape mismatches at compile time instead of at 2 AM when a customer sends a screenshot of a broken chart. I’ve run this stack across several production projects. The largest processed 70,000+ data points per chart without frame drops.
We’ll cover project setup, then build three components you’ll actually reuse: an animated line chart, a heat map, and a combined dashboard. The final section covers performance verification and catching regressions before users see them.
Installation & Project Setup
Vite is the quickest path to a TypeScript project. It compiles TypeScript automatically and spins up a hot-reload dev server — no webpack config to wrestle with.
npm create vite@latest d3-dashboard -- --template vanilla-ts
cd d3-dashboard
npm install
Now install D3 and its TypeScript definitions:
npm install d3
npm install --save-dev @types/d3
The @types/d3 package covers the entire D3 API — no guessing what scaleLinear() returns or what arguments axisBottom() accepts.
Open tsconfig.json and confirm strict mode is on:
{
"compilerOptions": {
"strict": true,
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler"
}
}
Strict mode requires null checks everywhere — critical when D3 selects DOM elements that might not exist when the code runs.
Building the Charts
Line Chart with Animated Drawing
Create src/lineChart.ts. It demonstrates the three D3 patterns you’ll reach for in almost every chart: scales, axes, and path rendering.
import * as d3 from 'd3';
interface DataPoint {
date: Date;
value: number;
}
export function drawLineChart(
selector: string,
data: DataPoint[]
): void {
const margin = { top: 20, right: 30, bottom: 40, left: 50 };
const width = 700 - margin.left - margin.right;
const height = 400 - margin.top - margin.bottom;
d3.select(selector).selectAll('*').remove();
const svg = d3
.select(selector)
.append('svg')
.attr('width', width + margin.left + margin.right)
.attr('height', height + margin.top + margin.bottom)
.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`);
const x = d3.scaleTime()
.domain(d3.extent(data, d => d.date) as [Date, Date])
.range([0, width]);
const y = d3.scaleLinear()
.domain([0, d3.max(data, d => d.value) as number])
.nice()
.range([height, 0]);
svg.append('g')
.attr('transform', `translate(0,${height})`)
.call(d3.axisBottom(x).ticks(6));
svg.append('g').call(d3.axisLeft(y));
const line = d3.line<DataPoint>()
.x(d => x(d.date))
.y(d => y(d.value))
.curve(d3.curveMonotoneX);
const path = svg.append('path')
.datum(data)
.attr('fill', 'none')
.attr('stroke', '#4f8ef7')
.attr('stroke-width', 2.5)
.attr('d', line);
// Draw-on animation using stroke-dashoffset trick
const totalLength = (path.node() as SVGPathElement).getTotalLength();
path
.attr('stroke-dasharray', `${totalLength} ${totalLength}`)
.attr('stroke-dashoffset', totalLength)
.transition()
.duration(1200)
.ease(d3.easeCubicOut)
.attr('stroke-dashoffset', 0);
// Tooltip
const tooltip = d3.select('body')
.append('div')
.style('position', 'absolute')
.style('background', '#333')
.style('color', '#fff')
.style('padding', '6px 10px')
.style('border-radius', '4px')
.style('pointer-events', 'none')
.style('opacity', 0);
svg.selectAll('circle')
.data(data)
.join('circle')
.attr('cx', d => x(d.date))
.attr('cy', d => y(d.value))
.attr('r', 4)
.attr('fill', '#4f8ef7')
.on('mouseover', (event, d) => {
tooltip.transition().duration(150).style('opacity', 1);
tooltip
.html(`${d.date.toLocaleDateString()}: <strong>${d.value}</strong>`)
.style('left', `${event.pageX + 12}px`)
.style('top', `${event.pageY - 28}px`);
})
.on('mouseout', () =>
tooltip.transition().duration(200).style('opacity', 0)
);
}
The animation runs via the stroke-dasharray / stroke-dashoffset SVG trick: set the dash length equal to the total path length, then transition the offset to zero over 1,200ms. The line draws itself. The cast path.node() as SVGPathElement tells TypeScript this is an SVG path — not a generic HTML element — so getTotalLength() resolves without a type error.
Heat Map for Pattern Discovery
Heat maps compress two-dimensional density into something immediately readable — server load by day and hour, weekly commit activity, sales patterns. Any data with two categorical axes and a numeric intensity fits this shape. Create src/heatMap.ts:
import * as d3 from 'd3';
interface HeatCell {
day: string;
hour: number;
value: number;
}
export function drawHeatMap(selector: string, data: HeatCell[]): void {
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const hours = d3.range(0, 24);
const cellSize = 28;
const margin = { top: 40, right: 20, bottom: 20, left: 50 };
d3.select(selector).selectAll('*').remove();
const svg = d3
.select(selector)
.append('svg')
.attr('width', hours.length * cellSize + margin.left + margin.right)
.attr('height', days.length * cellSize + margin.top + margin.bottom)
.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`);
const colorScale = d3
.scaleSequential()
.domain([0, d3.max(data, d => d.value) as number])
.interpolator(d3.interpolateYlOrRd);
svg.selectAll('.hour-label')
.data(hours)
.join('text')
.attr('x', d => d * cellSize + cellSize / 2)
.attr('y', -8)
.attr('text-anchor', 'middle')
.attr('font-size', 10)
.text(d => d % 3 === 0 ? `${d}h` : '');
svg.selectAll('.day-label')
.data(days)
.join('text')
.attr('x', -8)
.attr('y', (_, i) => i * cellSize + cellSize / 2)
.attr('dominant-baseline', 'middle')
.attr('text-anchor', 'end')
.attr('font-size', 11)
.text(d => d);
svg.selectAll('rect')
.data(data)
.join('rect')
.attr('x', d => d.hour * cellSize)
.attr('y', d => days.indexOf(d.day) * cellSize)
.attr('width', cellSize - 2)
.attr('height', cellSize - 2)
.attr('rx', 3)
.attr('fill', d => colorScale(d.value))
.append('title')
.text(d => `${d.day} ${d.hour}:00 — ${d.value} events`);
}
d3.scaleSequential with interpolateYlOrRd maps your numeric domain to a yellow-to-red gradient automatically. Each <title> appended to a rect gives you native browser tooltips — no positioning math, no extra library.
Wiring Up the Dashboard
In src/main.ts, compose both charts using a shared data source:
import * as d3 from 'd3';
import { drawLineChart } from './lineChart';
import { drawHeatMap } from './heatMap';
async function bootstrap() {
// Replace these with real fetch() calls when connecting to an API
const lineData = d3
.timeDays(new Date('2025-01-01'), new Date('2025-07-01'))
.map(date => ({
date,
value: Math.round(
50 + Math.random() * 80 + Math.sin(date.getMonth()) * 30
),
}));
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const heatData = days.flatMap(day =>
d3.range(0, 24).map(hour => ({
day,
hour,
value: Math.round(Math.random() * 100),
}))
);
drawLineChart('#line-chart', lineData);
drawHeatMap('#heat-map', heatData);
}
bootstrap();
The HTML side is minimal:
<div id="line-chart"></div>
<div id="heat-map" style="margin-top: 2rem"></div>
When your real API is ready, swap Math.random() for await fetch('/api/metrics').then(r => r.json()). The chart functions stay identical — only the data source changes.
Verification & Monitoring
Check TypeScript correctness in CI with the compiler’s no-emit mode — it runs type checks without producing output files:
npx tsc --noEmit
Zero errors means your data interfaces align with what D3 expects. Run this on every pull request to catch API schema changes before they reach production.
Profile rendering performance in Chrome DevTools under the Performance tab. Record while loading 10,000 data points. Long red bars in the flame chart usually mean too many SVG elements. Switch tooltip-layer elements to Canvas while keeping SVG for axes and labels — D3 supports both renderers in the same component.
Make charts responsive by reading the container width at draw time instead of hardcoding it:
const containerWidth = (
d3.select(selector).node() as HTMLElement
).getBoundingClientRect().width;
const width = containerWidth - margin.left - margin.right;
Attach a ResizeObserver to re-run the draw function whenever the viewport changes:
const container = document.querySelector(selector) as HTMLElement;
new ResizeObserver(() => drawLineChart(selector, data)).observe(container);
Desktop and mobile, handled in one place. I include this pattern in every new dashboard now without thinking twice.
Monitor layout stability with the Web Vitals library. SVG resizing after first paint causes Cumulative Layout Shift, which hurts Core Web Vitals scores:
npm install web-vitals
import { onCLS, onLCP } from 'web-vitals';
onCLS(metric => console.log('CLS:', metric.value));
onLCP(metric => console.log('LCP:', metric.value));
A CLS score above 0.1 means the SVG dimensions aren’t declared before first paint. Add explicit width and height attributes to the root <svg>, or wrap the container div in a CSS aspect-ratio rule.
Start the dev server and open the dashboard:
npm run dev
The line chart draws itself with a smooth 1.2-second cubic animation. The heat map fills in from pale yellow to deep red. Hover any dot on the line chart — the tooltip appears in 150ms and fades out cleanly on mouseout.
Three clear next moves: add zoom with d3.zoom(), link the charts so clicking a day on the heat map filters the line chart, or replace the random generator with real API data. The typed interfaces, reusable draw functions, and scale-based coordinate mapping you just built are the same patterns you’ll reach for in every D3 project after this.

