Data Visualization & Charts (Recharts, D3, Chart.js)
How charts actually get drawn — SVG vs Canvas, the scale abstraction that maps data to pixels — and how to pick between declarative React charting (Recharts), imperative low-level control (D3), and Canvas-based libraries (Chart.js), plus the accessibility most charts forget.
The big picture (read this first). Every chart is the same idea: take numbers in data space (dollars, dates, counts) and map them to pixel space (x/y positions, heights, colors) inside a drawing surface. Three decisions define any charting solution: (1) what surface do you draw on — SVG or Canvas; (2) how do you map data to pixels — the scale abstraction; and (3) how much of that do you write yourself vs. let a library do. Understanding these three lets you reason about any charting library instead of memorizing APIs.
SVG vs Canvas — the foundational choice. SVG is retained-mode: every bar, line, and label is a real DOM element you can style with CSS, attach event handlers to, and inspect. That makes SVG great for interactivity, accessibility, and crisp scaling, but it degrades once you have thousands of elements because each is a live DOM node. Canvas is immediate-mode: you issue draw commands to a bitmap and the browser forgets the shapes — there's no DOM, so it renders tens of thousands of points fast, but you lose per-element events, CSS, and built-in accessibility (you rebuild hit-testing yourself). Rule of thumb: SVG for typical dashboards (hundreds of elements, rich interaction), Canvas (or WebGL) for high-density data (large scatter plots, real-time streams).
Scales are the heart of every chart. A scale is a pure function from a data domain to a visual range. A linear scale maps [0, maxValue] -> [chartHeight, 0] (note the flip — SVG's y-axis grows downward, but charts grow upward). A band scale maps discrete categories to evenly spaced slots with padding (bar charts). A time scale maps Date objects to pixels. A color/ordinal scale maps categories to a palette. D3's d3-scale is the canonical implementation, and even when you use Recharts you are configuring scales under the hood. If you internalize domain -> range, you understand charting.
D3 is two libraries in a trench coat. People conflate them, but D3 has a utility half and a DOM half. The utility modules — d3-scale, d3-shape (line/area/arc/pie generators), d3-array, d3-time, d3-format — are pure math with no DOM and pair beautifully with React (let D3 compute path strings and scales; let React render the SVG). The DOM half — d3-selection's enter/update/exit data-join — imperatively creates and mutates elements, which fights React because both want to own the DOM. The modern React-D3 pattern is: D3 for math, React for rendering; reach for d3-selection only for complex transitions React can't easily express.
Recharts — declarative React charting. Recharts composes a chart from JSX components: a container chart (<BarChart>, <LineChart>, <PieChart>), axes (<XAxis>, <YAxis>), decorations (<Tooltip>, <Legend>, <CartesianGrid>), and data series (<Bar>, <Line>, <Area>, <Pie>) that each bind to a field via dataKey. It renders SVG under the hood and reads like markup that mirrors your data shape. Wrapping in <ResponsiveContainer> makes the chart fluidly fill its parent width at a fixed height with no resize code. It's the right default for standard React charts because it's declarative and integrates cleanly with component state.
Chart.js — Canvas-based, config-driven. Chart.js draws to a <canvas> from a single config object (type, data, options). Because it's Canvas, it handles denser datasets than SVG libraries and has a small, batteries-included feature set (animations, tooltips, legends). The trade is Canvas's: no DOM per element, so styling and accessibility are more limited and you configure rather than compose. Its React wrapper (react-chartjs-2) just marshals props into that config. Reach for it when you want a solid, dense, standard chart with minimal ceremony.
Picking the tool (the matrix).
| Approach | Surface | Best for | Trade-off |
|---|---|---|---|
| Recharts | SVG | Standard React charts, rich interaction | Declarative & fast; limited for unusual visuals |
| Chart.js | Canvas | Dense standard charts, quick setup | Config-driven; weaker per-element styling & a11y |
| D3 (utils + React) | SVG | Custom/bespoke visualizations | Full control; you write the render logic |
| D3 (full selection) | SVG/Canvas | Complex data-driven transitions | Maximum power; fights React, most code |
Match the tool to the need rather than defaulting to the most powerful one — D3 for everything is a common over-engineering mistake.
Accessibility is where most charts fail. A chart that only conveys meaning through color and pixels is invisible to screen-reader users and hostile to the color-blind. The fixes: give the SVG role="img" and a descriptive aria-label or <title>/<desc>; provide the underlying data as a visually-hidden <table> so assistive tech and keyboard users can read the numbers; never rely on color alone — add patterns, direct labels, or shape encodings and check contrast; ensure interactive points are keyboard-focusable with visible focus. A good chart has a text alternative that stands on its own.
Responsiveness and the retina/DPR trap. SVG scales cleanly because it's vector, but you still need to recompute scales when the container resizes (Recharts' ResponsiveContainer or a ResizeObserver). Canvas has a sharper gotcha: on high-DPR (retina) screens a canvas rendered at CSS pixels looks blurry — you must set the backing store to width * devicePixelRatio, keep the CSS size fixed, and ctx.scale(dpr, dpr). Forgetting DPR is the #1 reason Canvas charts look fuzzy.
Performance and data volume. The failure mode is rendering more than the surface can handle: thousands of SVG nodes janks layout; unthrottled real-time updates blow the frame budget. Mitigations: for SVG, cap element count, aggregate/bin data, and virtualize; for high density switch to Canvas/WebGL; downsample time series (e.g. LTTB) before plotting; throttle streaming updates to one render per animation frame with requestAnimationFrame; and memoize scale computations so you don't recompute them on every render.
Encodings and honesty. Choosing the right chart is half the job: bar for comparing categories, line for trends over time, scatter for correlation, pie only for a few parts of a whole (and rarely — humans read angles poorly). Be honest with axes: truncating a bar chart's y-axis (not starting at zero) exaggerates differences and is a classic misleading-visualization mistake. Line charts may start off zero to show trend detail, but bars must anchor at zero because their length is the encoding.
The mental model (memorise this). A chart maps data space to pixel space through a scale (domain -> range) drawn on a surface (SVG = DOM elements, styleable/interactive/accessible but caps out in the thousands; Canvas = bitmap, dense and fast but no DOM/events/a11y). Use Recharts for standard declarative React charts, D3's utility half for bespoke SVG (math in D3, rendering in React), Chart.js for dense Canvas charts — and always ship a text/table alternative and never encode meaning by color alone.
A scale is a pure mapping function — domain -> range — exactly like a serializer/mapper that converts a domain object into a transport DTO; get the mapping right and everything downstream is trivial. SVG vs Canvas is the classic ORM-vs-raw-JDBC trade: SVG (retained-mode, one managed object per row) gives you rich, inspectable, event-bound entities but degrades on huge result sets; Canvas (immediate-mode) is a raw batch write to a buffer — blazing fast at volume but you get no managed objects, no per-row hooks, no free tooling. D3's split mirrors a library with pure calculation utilities (safe to embed anywhere, like a stateless helper) versus a stateful component that owns and mutates shared state (d3-selection owning the DOM) and therefore fights another framework that wants the same ownership (React). Downsampling a time series before plotting is pagination/aggregation at the query layer so you never ship a million rows to the client.
- Every chart maps data space to pixel space via a scale (a pure domain -> range function); internalize domain->range and charting demystifies.
- SVG is retained-mode: real DOM elements, styleable/interactive/accessible/crisp, but degrades past ~thousands of nodes.
- Canvas is immediate-mode: a bitmap with no DOM, so it renders tens of thousands of points fast but loses per-element events, CSS, and built-in a11y.
- Linear scales map [0,max]->[height,0] (flipped because SVG y grows downward); band scales map categories to slots; time and ordinal/color scales map dates and categories.
- D3 is two halves: pure utility modules (d3-scale, d3-shape) that pair with React, and d3-selection's DOM data-join that fights React — modern pattern is D3 for math, React for rendering.
- Recharts (SVG, declarative JSX + dataKey + ResponsiveContainer) for standard React charts; Chart.js (Canvas, config object) for dense standard charts; D3 for bespoke visualizations.
- Accessibility most charts skip: role=img + aria-label/title/desc, a visually-hidden data table, never color alone (add patterns/labels), and keyboard-focusable interactive points.
- Canvas on retina looks blurry unless you size the backing store to width*devicePixelRatio and ctx.scale(dpr, dpr).
- For volume: cap SVG nodes, switch to Canvas/WebGL for density, downsample time series, throttle streaming to one render per requestAnimationFrame, and memoize scale computations.
- Be honest with encodings: bars must start at zero (length is the encoding); truncated axes exaggerate differences and mislead.
Worked Code
import {
ResponsiveContainer, BarChart, Bar, LineChart, Line,
XAxis, YAxis, Tooltip, Legend, CartesianGrid,
} from "recharts";
const monthly = [
{ month: "Jan", meals: 4200, travel: 8500 },
{ month: "Feb", meals: 3800, travel: 6200 },
{ month: "Mar", meals: 5100, travel: 7300 },
];
export function ExpenseCharts() {
return (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
{/* ResponsiveContainer: percentage width + fixed height -> fluid, no resize code */}
<ResponsiveContainer width="100%" height={300}>
<BarChart data={monthly}>
<CartesianGrid strokeDasharray="3 3" />
{/* each series binds to a field via dataKey; renders SVG under the hood */}
<XAxis dataKey="month" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="meals" fill="#1B4F72" />
<Bar dataKey="travel" fill="#E67E22" />
</BarChart>
</ResponsiveContainer>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={monthly}>
<XAxis dataKey="month" />
<YAxis />
<Tooltip />
<Line type="monotone" dataKey="meals" stroke="#1B4F72" />
<Line type="monotone" dataKey="travel" stroke="#E67E22" />
</LineChart>
</ResponsiveContainer>
</div>
);
}import { scaleBand, scaleLinear } from "d3-scale";
import { max } from "d3-array";
// D3's UTILITY half is pure math (no DOM) — safe to use inside React.
// React owns the SVG; D3 only computes scales and geometry.
export function BarChart({ data }: { data: { label: string; value: number }[] }) {
const width = 400, height = 240, pad = 24;
// band scale: discrete categories -> evenly spaced x slots with padding
const x = scaleBand<string>()
.domain(data.map((d) => d.label))
.range([pad, width - pad])
.padding(0.2);
// linear scale: [0, max] -> [height, 0] (FLIPPED: SVG y grows downward)
const y = scaleLinear()
.domain([0, max(data, (d) => d.value) ?? 0])
.range([height - pad, pad]);
return (
<svg width={width} height={height} role="img"
aria-label="Expenses by category">
{data.map((d) => (
<rect
key={d.label}
x={x(d.label)}
y={y(d.value)}
width={x.bandwidth()}
height={y(0) - y(d.value)}
fill="#1B4F72"
/>
))}
</svg>
);
}import Chart from "chart.js/auto";
// Chart.js draws to <canvas> from ONE config object: type, data, options.
// Canvas = immediate-mode bitmap: dense/fast, but no DOM per element.
const ctx = document.querySelector("#chart").getContext("2d");
new Chart(ctx, {
type: "bar",
data: {
labels: ["Jan", "Feb", "Mar"],
datasets: [
{ label: "Meals", data: [4200, 3800, 5100], backgroundColor: "#1B4F72" },
{ label: "Travel", data: [8500, 6200, 7300], backgroundColor: "#E67E22" },
],
},
options: {
responsive: true,
// Chart.js handles DPR for you when devicePixelRatio is read from window;
// for a RAW canvas you must do it yourself (see below) or retina looks blurry.
scales: { y: { beginAtZero: true } }, // bars must anchor at zero
},
});
// RAW canvas retina fix (what libraries do under the hood):
function fixDpr(canvas, cssW, cssH) {
const dpr = window.devicePixelRatio || 1;
canvas.width = cssW * dpr; // backing store in device pixels
canvas.height = cssH * dpr;
canvas.style.width = cssW + "px"; // CSS size stays in CSS pixels
canvas.style.height = cssH + "px";
canvas.getContext("2d").scale(dpr, dpr); // draw in CSS-pixel coordinates
}// A chart must not convey meaning by pixels/color alone.
// 1) Describe the SVG for screen readers.
// 2) Provide the raw numbers as a visually-hidden table.
// 3) Never rely on color alone — pair color with a label/pattern.
export function AccessibleChart({ data }) {
return (
<figure>
<svg role="img" aria-labelledby="chartTitle chartDesc" width={400} height={240}>
<title id="chartTitle">Monthly expenses</title>
<desc id="chartDesc">
Bar chart of meals and travel spend for Jan through Mar.
</desc>
{/* ...bars... */}
</svg>
{/* Visually hidden but read by AT and keyboard users */}
<table className="sr-only">
<caption>Monthly expenses (USD)</caption>
<thead><tr><th>Month</th><th>Meals</th><th>Travel</th></tr></thead>
<tbody>
{data.map((d) => (
<tr key={d.month}>
<td>{d.month}</td><td>{d.meals}</td><td>{d.travel}</td>
</tr>
))}
</tbody>
</table>
</figure>
);
}
// .sr-only { position:absolute; width:1px; height:1px; overflow:hidden;
// clip:rect(0 0 0 0); white-space:nowrap; border:0; }▶Try It Live
Edit the code and press Run — it executes safely in a sandboxed iframe. Use the Console tab for log output.
Interview-Ready Q&A
SVG is retained-mode — every shape is a real DOM element you can style with CSS, attach events to, and expose to assistive tech — so it's ideal for typical dashboards with hundreds of elements and rich interaction, and it scales crisply. But each element is a live DOM node, so SVG degrades past thousands of shapes. Canvas is immediate-mode — you draw to a bitmap and the browser forgets the shapes — so it renders tens of thousands of points fast, but you lose per-element events, CSS, and built-in accessibility (you rebuild hit-testing yourself). Rule of thumb: SVG for normal interactive charts, Canvas or WebGL for high-density or real-time data.
- 1Every chart maps data space to pixel space through a scale (domain -> range).
- 2SVG = retained-mode DOM elements: styleable, interactive, accessible, crisp — but degrades past thousands of nodes.
- 3Canvas = immediate-mode bitmap: dense and fast, but no DOM, events, CSS, or built-in a11y.
- 4Linear scale flips [0,max] -> [height,0] because SVG y grows downward; band scales handle categories.
- 5D3 = utility math (d3-scale/d3-shape, pairs with React) + d3-selection DOM join (fights React).
- 6Recharts (SVG, declarative) for standard React charts; Chart.js (Canvas, config) for dense; D3 for bespoke.
- 7Accessibility: role=img + aria-label/title/desc, a hidden data table, never color alone, keyboard-focusable points.
- 8Retina Canvas needs backing store = cssSize * devicePixelRatio plus ctx.scale(dpr, dpr) or it's blurry.
- 9For volume: downsample time series, throttle streams to rAF, cap SVG nodes, memoize scales.
- 10Bars must start at zero — a truncated axis exaggerates differences and misleads.