Skip to content
JavaScript js canvas 6 min read

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 max from 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.

NeedHand-rolled canvasLibrary (Chart.js, D3, ECharts)
Static bar/lineIdeal — tiny, no depsOverkill
Tooltips & hoverManual hit-testingBuilt in
Axes, ticks, legendsYou write themGenerated automatically
Animations & transitionsManual requestAnimationFrameDeclarative
Many chart typesEach from scratchOne API
Bundle sizeSmallest possibleTens 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.width to cssWidth * devicePixelRatio and 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.
Last updated June 1, 2026
Was this helpful?