In this module you will learn to draw directly on the HTML Canvas element — the lowest level of client-side charting. Before any library can render a bar chart or a line graph, something has to paint pixels onto a surface. That something is the Canvas 2D API. Understanding it gives you insight into what every charting library does behind the scenes.
Run: Open the HTML files directly in a browser. No server or build step required.
The <canvas> element is a blank rectangular area in which JavaScript can draw pixels. It was introduced in HTML5 and is now supported by every modern browser. Unlike SVG, which uses a retained-mode DOM of shapes, Canvas is immediate-mode: you issue drawing commands and the pixels are painted. There is no DOM tree of shapes to inspect or style with CSS afterward.
<canvas id="myChart" width="600" height="400"></canvas>
<script>
const canvas = document.getElementById('myChart');
const ctx = canvas.getContext('2d');
// ctx is a CanvasRenderingContext2D object
// All drawing methods are called on ctx
ctx.fillStyle = '#16a085';
ctx.fillRect(50, 50, 200, 100);
</script>
The getContext('2d') call returns a CanvasRenderingContext2D object. Every drawing operation — rectangles, lines, text, arcs — is a method on this context object.
width and height attributes on the <canvas> element set the drawing surface resolution in pixels. CSS width/height only stretch or shrink the display. If you set the drawing surface to 300x150 (the default) but display it at 600x400 via CSS, everything will look blurry. Always set the attributes explicitly.
Canvas uses a coordinate system where the origin (0, 0) is the top-left corner. The x-axis increases to the right and the y-axis increases downward. This is the opposite of the mathematical convention where y increases upward, and it has a direct impact on how you map data values to pixel positions.
When you draw a bar chart, taller bars need smaller y values (closer to the top of the canvas). This is the single most common source of confusion for students drawing their first chart on Canvas. You will see the practical consequence of this in the bar chart section below.
The 2D context provides a small set of methods that cover almost every charting need:
// Filled rectangle ctx.fillStyle = '#2ecc71'; ctx.fillRect(x, y, width, height); // Outlined rectangle (stroke only) ctx.strokeStyle = '#333'; ctx.lineWidth = 2; ctx.strokeRect(x, y, width, height); // Clear a rectangular area (erase pixels) ctx.clearRect(x, y, width, height);
Rectangles are the workhorse of bar charts. Each bar is a single fillRect call.
// Draw a line from (50,50) to (200,150) ctx.beginPath(); ctx.moveTo(50, 50); // Move pen to start ctx.lineTo(200, 150); // Draw line to end ctx.strokeStyle = '#333'; ctx.lineWidth = 2; ctx.stroke(); // Actually render the path // Draw a triangle (closed path) ctx.beginPath(); ctx.moveTo(300, 50); ctx.lineTo(350, 150); ctx.lineTo(250, 150); ctx.closePath(); // Close back to start ctx.fillStyle = '#3498db'; ctx.fill();
The path system follows a pen metaphor: beginPath() lifts the pen, moveTo() moves it without drawing, lineTo() draws a straight line, and stroke() or fill() renders the accumulated path. Axis lines, gridlines, and line charts all use this system.
// Full circle ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2); ctx.fillStyle = '#e74c3c'; ctx.fill(); // Half circle (arc from 0 to PI) ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI); ctx.stroke();
The arc() method takes a center point, radius, start angle, and end angle in radians. A full circle goes from 0 to 2π. Pie charts and donut charts are sequences of arc calls with different start and end angles.
ctx.font = '14px -apple-system, sans-serif';
ctx.fillStyle = '#333';
ctx.textAlign = 'center'; // 'left', 'center', 'right'
ctx.textBaseline = 'top'; // 'top', 'middle', 'bottom'
ctx.fillText('Hello Canvas', x, y);
// Measure text width (useful for positioning)
const metrics = ctx.measureText('Hello Canvas');
console.log(metrics.width); // e.g., 98.5
Text rendering is essential for axis labels, chart titles, and data annotations. The textAlign and textBaseline properties control where the text is placed relative to the (x, y) coordinate.
The central challenge of drawing a chart is converting data values (like "1250 pageviews") into pixel coordinates on the canvas. This is the concept of a scale function — a mapping from a data domain to a pixel range.
A linear scale function for mapping a data value to a vertical pixel position looks like this:
// Scale function: data value → pixel y-coordinate
function scaleY(value, maxValue, chartTop, chartHeight) {
// Normalize to 0..1, multiply by chart height, invert for canvas coords
return chartTop + chartHeight - (value / maxValue) * chartHeight;
}
// Example: bar for 890 pageviews on a chart from y=50 to y=350
const pixelY = scaleY(890, 1250, 50, 300);
// pixelY = 50 + 300 - (890/1250)*300 = 50 + 300 - 213.6 = 136.4
d3.scaleLinear(), Chart.js's internal scales) do this same arithmetic. Understanding the manual process makes it clear what those abstractions are doing for you.
On high-DPI screens (Retina MacBooks, modern phones), the physical pixel density is 2x or 3x the CSS pixel grid. A canvas set to 600x400 CSS pixels is actually backed by 1200x800 physical pixels on a 2x display. If you don't account for this, your chart will look blurry because the browser upscales the lower-resolution drawing surface.
const canvas = document.getElementById('myChart');
const ctx = canvas.getContext('2d');
// Get the device pixel ratio (1 on standard, 2 on Retina, 3 on some phones)
const dpr = window.devicePixelRatio || 1;
// Desired display size in CSS pixels
const displayWidth = 600;
const displayHeight = 400;
// Set the actual drawing surface to match physical pixels
canvas.width = displayWidth * dpr;
canvas.height = displayHeight * dpr;
// Scale the CSS size back down so it appears the right size
canvas.style.width = displayWidth + 'px';
canvas.style.height = displayHeight + 'px';
// Scale all drawing operations by dpr
ctx.scale(dpr, dpr);
// Now draw as if the canvas is 600x400 — it will be crisp on any screen
ctx.fillRect(50, 50, 200, 100);
After the ctx.scale(dpr, dpr) call, you can write all your drawing code using the logical CSS pixel coordinates (600x400). The context transform handles the physical pixel mapping transparently.
Let's bring everything together and build a bar chart showing pageviews by page. This is the same chart you can run in first-bar-chart.html.
const data = [
{ page: '/home', views: 1250 },
{ page: '/about', views: 430 },
{ page: '/products', views: 890 },
{ page: '/contact', views: 320 },
{ page: '/blog', views: 675 }
];
// Chart layout (in CSS pixels)
const margin = { top: 40, right: 20, bottom: 60, left: 60 };
const canvasWidth = 600;
const canvasHeight = 400;
const chartWidth = canvasWidth - margin.left - margin.right;
const chartHeight = canvasHeight - margin.top - margin.bottom;
The margin convention reserves space around the chart area for the title, axis labels, and tick marks. The chart itself draws within the inner rectangle. This is the same pattern used by D3.js and most other charting code.
const maxViews = Math.max(...data.map(d => d.views));
const barWidth = chartWidth / data.length * 0.7; // 70% of slot width
const gap = chartWidth / data.length * 0.3; // 30% gap
function scaleY(value) {
return margin.top + chartHeight - (value / maxViews) * chartHeight;
}
// Horizontal gridlines
ctx.strokeStyle = '#e0e0e0';
ctx.lineWidth = 1;
const gridSteps = 5;
for (let i = 0; i <= gridSteps; i++) {
const value = (maxViews / gridSteps) * i;
const y = scaleY(value);
ctx.beginPath();
ctx.moveTo(margin.left, y);
ctx.lineTo(margin.left + chartWidth, y);
ctx.stroke();
// Y-axis label
ctx.fillStyle = '#666';
ctx.font = '12px -apple-system, sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillText(Math.round(value).toString(), margin.left - 10, y);
}
// X-axis line
ctx.strokeStyle = '#333';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(margin.left, margin.top + chartHeight);
ctx.lineTo(margin.left + chartWidth, margin.top + chartHeight);
ctx.stroke();
data.forEach((d, i) => {
const slotWidth = chartWidth / data.length;
const x = margin.left + i * slotWidth + (slotWidth - barWidth) / 2;
const barHeight = (d.views / maxViews) * chartHeight;
const y = margin.top + chartHeight - barHeight;
// Draw bar
ctx.fillStyle = '#16a085';
ctx.fillRect(x, y, barWidth, barHeight);
// X-axis label
ctx.fillStyle = '#333';
ctx.font = '12px -apple-system, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText(d.page, x + barWidth / 2, margin.top + chartHeight + 8);
// Value label above bar
ctx.fillStyle = '#16a085';
ctx.font = 'bold 12px -apple-system, sans-serif';
ctx.textBaseline = 'bottom';
ctx.fillText(d.views.toLocaleString(), x + barWidth / 2, y - 4);
});
ctx.fillStyle = '#333';
ctx.font = 'bold 16px -apple-system, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText('Pageviews by Page', canvasWidth / 2, 10);
That is the complete bar chart. Every charting library in the world performs these same five steps: define layout, compute scales, draw axes, draw data marks, and add annotations. The library just hides the boilerplate behind a configuration object.
The Canvas API is one of two primary rendering technologies for charts in the browser. The other is SVG (Scalable Vector Graphics). Here is how they compare:
| Factor | Canvas | SVG |
|---|---|---|
| Rendering mode | Immediate (pixels) | Retained (DOM nodes) |
| Performance with many elements | Excellent (10,000+ points) | Degrades (DOM overhead) |
| Interactivity | Manual hit-testing | Native DOM events on each element |
| Accessibility | Opaque bitmap (needs ARIA) | Structured, inspectable markup |
| Resolution independence | Requires HiDPI handling | Scales automatically |
| CSS styling | Not applicable | Full CSS support |
| Used by | Chart.js | D3.js (typically) |
Chart.js uses Canvas internally. D3.js typically outputs SVG. Both are valid choices. For analytics dashboards with many data points, Canvas often wins on performance. For interactive, accessible visualizations, SVG has advantages. You will use both in this tutorial series.
<canvas> element provides a pixel-level drawing surface via getContext('2d')fillRect, strokeRect, beginPath/moveTo/lineTo/stroke, and fillText cover most charting needsdevicePixelRatio to render crisp charts on HiDPI/Retina screens