Drawing Charts from Scratch
Charting libraries are convenient, but the entire concept of a chart is just arithmetic: take numbers in your data’s units, convert them to pixel coordinates, and draw lines and rectangles. Once you understand that conversion — the scale — you can render bar charts, line charts, and sparklines directly on a <canvas> with a few dozen lines of code. This page builds a chart from first principles so the magic disappears.
The core idea: scales
A scale is a function that maps a value from your data’s domain (e.g. revenue from 0 to 500) into the canvas’s range (e.g. pixels from the chart bottom up to the top). Linear scaling is the workhorse, and the formula is the same one you learned for interpolation:
// Map `value` from [domainMin, domainMax] onto [rangeMin, rangeMax].
const linearScale = (value, domainMin, domainMax, rangeMin, rangeMax) =>
rangeMin + ((value - domainMin) / (domainMax - domainMin)) * (rangeMax - rangeMin);
// 250 revenue, on a 0–500 domain, drawn into a 0–400px tall plot:
console.log(linearScale(250, 0, 500, 0, 400)); // mid-height
Output:
200
Because canvas Y grows downward, you flip the range so larger values sit higher on screen. If your plot area spans from top to bottom in pixels, scale into [bottom, top] rather than [top, bottom].
Setting up the plot area with margins
Never draw a chart edge-to-edge — you need room for axis labels and ticks. Reserve a margin on each side and treat the inner rectangle as your plot area. Everything (bars, points, gridlines) is positioned relative to that inner box.
const margin = { top: 20, right: 20, bottom: 40, left: 50 };
const plotWidth = canvas.width - margin.left - margin.right;
const plotHeight = canvas.height - margin.top - margin.bottom;
// Helpers that account for margins:
const xToPixel = (i, count) =>
margin.left + (i + 0.5) * (plotWidth / count); // category centers
const yToPixel = (v, max) =>
margin.top + plotHeight - (v / max) * plotHeight; // value -> y (flipped)
Tip: Compute
maxfrom your data with a little headroom — e.g.Math.max(...data) * 1.1— so the tallest bar never touches the top edge.
A complete bar chart
The bar chart below is fully self-contained. It computes a max, draws horizontal gridlines with value labels, then renders one rectangle per data point with a category label underneath. Open it in CodePen to see it render live.
<canvas id="chart" width="600" height="360" style="background:#fff;border:1px solid #e2e8f0"></canvas>
<script>
const data = [
{ label: "Mon", value: 120 },
{ label: "Tue", value: 200 },
{ label: "Wed", value: 150 },
{ label: "Thu", value: 280 },
{ label: "Fri", value: 90 },
];
const canvas = document.getElementById("chart");
const ctx = canvas.getContext("2d");
const margin = { top: 20, right: 20, bottom: 40, left: 50 };
const plotW = canvas.width - margin.left - margin.right;
const plotH = canvas.height - margin.top - margin.bottom;
const max = Math.max(...data.map((d) => d.value)) * 1.1;
const yToPx = (v) => margin.top + plotH - (v / max) * plotH;
// Gridlines + Y labels
ctx.font = "12px system-ui, sans-serif";
ctx.fillStyle = "#64748b";
ctx.strokeStyle = "#edf2f7";
ctx.textAlign = "right";
ctx.textBaseline = "middle";
const ticks = 5;
for (let i = 0; i <= ticks; i++) {
const value = (max / ticks) * i;
const y = yToPx(value);
ctx.beginPath();
ctx.moveTo(margin.left, y);
ctx.lineTo(margin.left + plotW, y);
ctx.stroke();
ctx.fillText(Math.round(value), margin.left - 8, y);
}
// Bars + X labels
const slot = plotW / data.length;
const barW = slot * 0.6;
ctx.textAlign = "center";
data.forEach((d, i) => {
const x = margin.left + i * slot + (slot - barW) / 2;
const y = yToPx(d.value);
const h = margin.top + plotH - y;
ctx.fillStyle = "#6366f1";
ctx.fillRect(x, y, barW, h);
ctx.fillStyle = "#334155";
ctx.textBaseline = "top";
ctx.fillText(d.label, x + barW / 2, margin.top + plotH + 8);
});
</script>
The key moves: a single max drives both the gridlines and the bar heights, so they always line up. Bars sit inside evenly sized “slots” and are centered with a fixed width fraction (0.6), which keeps spacing consistent regardless of how many points you have.
Drawing the axes and a line chart
A line chart reuses the same scales but connects points with a Path2D-style stroke instead of filling rectangles. Add explicit axis lines along the left and bottom of the plot to anchor the data.
<canvas id="line" width="600" height="340" style="background:#fff;border:1px solid #e2e8f0"></canvas>
<script>
const series = [12, 19, 7, 22, 30, 18, 25];
const c = document.getElementById("line");
const ctx = c.getContext("2d");
const m = { top: 20, right: 20, bottom: 30, left: 40 };
const w = c.width - m.left - m.right;
const h = c.height - m.top - m.bottom;
const max = Math.max(...series) * 1.1;
const x = (i) => m.left + (i / (series.length - 1)) * w;
const y = (v) => m.top + h - (v / max) * h;
// Axes
ctx.strokeStyle = "#94a3b8";
ctx.beginPath();
ctx.moveTo(m.left, m.top);
ctx.lineTo(m.left, m.top + h);
ctx.lineTo(m.left + w, m.top + h);
ctx.stroke();
// Line
ctx.strokeStyle = "#10b981";
ctx.lineWidth = 2;
ctx.lineJoin = "round";
ctx.beginPath();
series.forEach((v, i) => (i === 0 ? ctx.moveTo(x(i), y(v)) : ctx.lineTo(x(i), y(v))));
ctx.stroke();
// Points
ctx.fillStyle = "#10b981";
series.forEach((v, i) => {
ctx.beginPath();
ctx.arc(x(i), y(v), 3, 0, Math.PI * 2);
ctx.fill();
});
</script>
Here the X scale divides the width into length - 1 equal gaps so the first point hugs the Y axis and the last reaches the right edge — the natural layout for a continuous time series.
When to reach for a chart library
Hand-rolled charts are perfect for fixed, lightweight visuals: a single sparkline, a dashboard tile, or a custom render that no library supports. Once you need interactivity and polish, a library earns its weight.
| Need | Hand-rolled canvas | Library (Chart.js, D3, ECharts) |
|---|---|---|
| Static bar/line | Ideal — tiny, no deps | Overkill |
| Tooltips & hover | Manual hit-testing | Built in |
| Axes, ticks, legends | You write them | Generated automatically |
| Animations & transitions | Manual requestAnimationFrame | Declarative |
| Many chart types | Each from scratch | One API |
| Bundle size | Smallest possible | Tens to hundreds of KB |
Warning: Hit-testing on canvas is harder than on SVG — there are no DOM elements to attach listeners to, so you must map mouse coordinates back to data yourself. If your chart needs rich interactivity, weigh SVG or a library before committing to raw canvas.
Best Practices
- Separate your scale logic from your drawing logic — pure functions that map data to pixels are easy to test and reuse across chart types.
- Always reserve margins and derive the plot area from them; never hardcode pixel positions for bars or ticks.
- Compute the domain max from the data with ~10% headroom so values never clip the top edge.
- Remember canvas Y is inverted: subtract from the bottom of the plot when converting values to pixels.
- Scale for high-DPI displays by setting
canvas.widthtocssWidth * devicePixelRatioand scaling the context, or text and lines will look fuzzy. - Call
ctx.beginPath()before each independent shape so strokes and fills don’t bleed into one another. - Reach for a charting library the moment you need tooltips, legends, or smooth animation — re-implementing those is rarely worth it.