Working with Paths and Lines

1

Working with Paths and Lines

▶

The HTML Canvas API draws shapes and lines through a concept called a path. Unlike simple rectangle methods such as fillRect(), which render immediately, a path is an invisible blueprint — a sequence of points, line segments, arcs, and curves that you define step by step. Only when you explicitly ask the canvas to render that blueprint, by calling stroke() or fill(), does anything become visible. This separation between defining a shape and drawing it is what makes the path system so powerful and flexible. You can build arbitrarily complex shapes — triangles, stars, custom icons, data charts — all from the same small set of path methods.

To work with paths effectively you need to understand each stage of the process: opening a fresh path, positioning the drawing cursor, laying out line segments, optionally closing the shape, and finally rendering it with the right style. Each stage has specific rules that, when misunderstood, produce subtle bugs like shapes bleeding into one another or lines appearing in unexpected colors. The sections below walk through every part of this system in depth, with practical examples that illustrate both the correct approach and the common pitfalls.

Understanding the Path Concept

Internally, the canvas maintains a single current path object. Think of it as a list of instructions stored in memory: "start here, draw a line to here, curve to here," and so on. At any given moment the canvas holds exactly one such list. Every path-building method you call — moveTo(), lineTo(), arc(), bezierCurveTo(), and others — appends an instruction to that list.

This current path can contain multiple sub-paths. A sub-path is a connected sequence of segments. When you pick up the pen and put it down somewhere else with moveTo(), you start a new sub-path, but both sub-paths still belong to the same current path object. This means a single call to stroke() or fill() renders all of them at once, which is efficient but also means they all share the same strokeStyle or fillStyle at the moment of rendering.

A critical rule: paths are never visible until you call stroke() or fill(). You can chain dozens of lineTo() calls and nothing will appear on screen. This is intentional — it lets you set up your styles and properties before committing the drawing to the canvas pixels.

Consider a simple conceptual example: drawing a triangle.

const canvas = document.getElementById('myCanvas');
const ctx = canvas.getContext('2d');

ctx.beginPath();          // Start a fresh path
ctx.moveTo(150, 50);      // Move pen to the top vertex
ctx.lineTo(250, 200);     // Line to bottom-right vertex
ctx.lineTo(50, 200);      // Line to bottom-left vertex
ctx.closePath();          // Close back to the starting point (150, 50)
ctx.stroke();             // Now render the triangle outline

Until stroke() is called, the canvas pixel buffer is completely unchanged. The path commands above only modify the internal path list.

Starting and Resetting a Path with beginPath()

ctx.beginPath() discards the current path list entirely and starts a new, empty one. This is the single most important habit to build when working with canvas paths. Forgetting to call it is the root cause of a large proportion of canvas rendering bugs encountered by beginners.

Here is why: suppose you draw a red line, then without calling beginPath() you try to draw a blue line. The second call to stroke() redraws everything in the current path — including the original red line — but now with the current strokeStyle, which is blue. The result is that the first line gets repainted blue on top of its original red color.

// BUG: forgetting beginPath()
ctx.strokeStyle = 'red';
ctx.moveTo(10, 10);
ctx.lineTo(200, 10);
ctx.stroke();             // Draws a red line

ctx.strokeStyle = 'blue';
ctx.moveTo(10, 50);
ctx.lineTo(200, 50);
ctx.stroke();             // Redraws BOTH lines in blue!
// CORRECT: using beginPath() before each shape
ctx.strokeStyle = 'red';
ctx.beginPath();
ctx.moveTo(10, 10);
ctx.lineTo(200, 10);
ctx.stroke();             // Draws a red line

ctx.strokeStyle = 'blue';
ctx.beginPath();
ctx.moveTo(10, 50);
ctx.lineTo(200, 50);
ctx.stroke();             // Draws only the blue line

An important clarification: beginPath() does not erase anything from the canvas surface. Pixels that have already been painted remain exactly where they are. It only resets the internal path definition in memory, giving you a clean slate to describe the next shape. This distinction matters: if you want to clear pixels from the canvas, you need ctx.clearRect().

Moving the Drawing Cursor with moveTo()

ctx.moveTo(x, y) places the drawing cursor at the specified canvas pixel coordinates without drawing anything. It is the equivalent of lifting a pen off paper, moving it to a new spot, and setting it down again. No line is created; only the current position changes.

After beginPath(), the cursor has no defined position. The very first thing you usually do is call moveTo() to establish a starting point. If you skip this and go straight to lineTo(), the canvas treats the current point as (0, 0), which often produces an unexpected diagonal line from the top-left corner.

moveTo() can be called multiple times within a single path to create disconnected sub-paths. Each moveTo() starts a new sub-path from that position. This is useful when you want to draw multiple separate shapes that will all share the same style and be rendered in a single stroke() call.

// Drawing two separate lines with one stroke() call
ctx.beginPath();

ctx.moveTo(20, 30);       // Start of first sub-path
ctx.lineTo(180, 30);      // First horizontal line

ctx.moveTo(20, 70);       // Start of second sub-path (disconnected)
ctx.lineTo(180, 70);      // Second horizontal line

ctx.strokeStyle = 'green';
ctx.lineWidth = 2;
ctx.stroke();             // Both lines rendered green at once

This technique is particularly efficient when drawing grids, tick marks, or any pattern made of many disconnected strokes that share the same appearance.

Drawing Straight Lines with lineTo()

ctx.lineTo(x, y) adds a straight-line segment from the current cursor position to the point (x, y), and then updates the current position to (x, y). Like all path commands, it is invisible until rendered.

Chaining multiple lineTo() calls produces a polyline — a series of connected straight segments. Because each call updates the current point, you do not need to repeat coordinates; the starting point of each new segment is automatically the ending point of the previous one.

// Drawing a lightning bolt / zigzag polyline
ctx.beginPath();
ctx.moveTo(100, 20);
ctx.lineTo(60,  100);
ctx.lineTo(100, 100);
ctx.lineTo(60,  180);
ctx.strokeStyle = '#f0c000';
ctx.lineWidth = 4;
ctx.stroke();

A common source of confusion is that lineTo() called immediately after beginPath() — without a prior moveTo() — behaves like a moveTo() for that very first call. This is because when the path is empty, the canvas implicitly moves to the target point rather than drawing a line from an undefined position. Relying on this implicit behavior is not recommended; always be explicit with moveTo() for clarity.

The line created by lineTo() will be rendered differently depending on whether you call stroke() or fill(). With stroke(), you see the line itself. With fill(), the canvas treats the sequence of points as a closed region and fills the interior area, which only makes sense for polylines that form a polygon.

Closing a Path with closePath()

ctx.closePath() draws a straight line segment from the current position back to the starting point of the current sub-path, and then marks the sub-path as closed. It is entirely optional for open shapes like a standalone line, but it is very useful when you are drawing polygons.

The practical difference between calling closePath() and manually drawing a lineTo() back to the origin is how line joins are handled at that corner. When you use closePath(), the join between the last segment and the closing segment uses the current lineJoin setting, producing a clean mitered, rounded, or beveled corner. If you use lineTo() back to the origin instead, the start and end of the path meet as two line caps placed at the same point, which often creates a visible notch or gap, especially at larger line widths.

// Polygon with a clean corner using closePath()
ctx.beginPath();
ctx.moveTo(150, 30);
ctx.lineTo(260, 180);
ctx.lineTo(40,  180);
ctx.closePath();           // Clean join at (150, 30)
ctx.lineWidth = 6;
ctx.strokeStyle = '#333';
ctx.stroke();

Another important point: closePath() does not end the path. After calling it you can continue adding more sub-paths to the same current path. It simply closes the current sub-path and moves the current point to the sub-path's starting point.

When fill() is called, all open sub-paths are automatically closed before filling, so closePath() is not strictly necessary before fill(). However, calling it explicitly is considered good practice because it makes your intent clear and ensures consistent behavior for stroke() as well.

Rendering Paths with stroke() and fill()

Once the path is defined, you make it visible with one or both of the rendering commands.

ctx.stroke() traces the outline of the path using the current ctx.strokeStyle (a color, gradient, or pattern), ctx.lineWidth, and any other line-related properties in effect at the time it is called. It does not close open sub-paths before drawing.

ctx.fill() fills the interior area of the path with the current ctx.fillStyle. It automatically closes any open sub-paths first. The "interior" is determined by the fill rule, which you can set as an optional argument: ctx.fill('nonzero') (the default) or ctx.fill('evenodd'), which matters for complex self-intersecting shapes.

You can call both on the same path. The order matters because the canvas is a pixel buffer and later operations paint on top of earlier ones. The standard convention is to call fill() first and then stroke(), so that the stroke outline appears on top of the filled area and is fully visible. If you call stroke() first and then fill(), the fill color will paint over half the stroke width (the inner half), effectively making the stroke appear thinner.

// Correct order: fill first, then stroke on top
ctx.beginPath();
ctx.moveTo(150, 30);
ctx.lineTo(260, 180);
ctx.lineTo(40,  180);
ctx.closePath();

ctx.fillStyle   = 'lightblue';
ctx.strokeStyle = 'navy';
ctx.lineWidth   = 4;

ctx.fill();    // Fill the interior first
ctx.stroke();  // Draw the outline on top

Controlling Line Appearance

The canvas context exposes several properties that control exactly how stroked lines look. These are all set on the context object and apply to any subsequent stroke() call.

lineWidth sets the thickness of the stroke in CSS pixels. The default is 1. The line is drawn centered on the mathematical path, so half the width extends to each side. This means a lineWidth of 4 extends 2 pixels on each side of the path coordinates. On high-DPI (Retina) displays you may need to scale the canvas to avoid blurry lines.

ctx.lineWidth = 8;   // Eight-pixel-wide stroke

lineCap controls the appearance of the two endpoints of an open sub-path. It has three options:

  • 'butt' (default) — The line ends exactly at the endpoint coordinates. No extension beyond the endpoint.
  • 'round' — A semicircle is added beyond each endpoint, with a diameter equal to lineWidth. This makes lines appear slightly longer than their defined coordinates.
  • 'square' — A rectangle half as long as lineWidth is added beyond each endpoint. Lines appear the same length as 'round' but with square ends. Useful for dashes that need a precise rectangular appearance.
// Demonstrating all three lineCap styles
const caps = ['butt', 'round', 'square'];
caps.forEach((cap, i) => {
    ctx.beginPath();
    ctx.lineCap = cap;
    ctx.lineWidth = 16;
    ctx.moveTo(50, 40 + i * 40);
    ctx.lineTo(250, 40 + i * 40);
    ctx.stroke();
});

lineJoin controls how two connected segments meet at a corner. It has three options:

  • 'miter' (default) — The outer edges of the two segments are extended until they meet at a sharp point. This can produce very long spikes on sharp angles. The miterLimit property (default 10) caps the spike length: when the miter would be too long, the join falls back to a bevel.
  • 'round' — A filled arc is drawn at the join, creating a rounded corner with a radius equal to half the lineWidth.
  • 'bevel' — The outer corner is cut off with a straight edge, producing a flat, angled join.
// Drawing a zigzag with each lineJoin type
const joins = ['miter', 'round', 'bevel'];
joins.forEach((join, i) => {
    ctx.beginPath();
    ctx.lineJoin = join;
    ctx.lineWidth = 12;
    ctx.moveTo(20,       60 + i * 80);
    ctx.lineTo(80,       20 + i * 80);
    ctx.lineTo(140,      60 + i * 80);
    ctx.stroke();
});

The table below summarises these line appearance properties at a glance:

Property Default Valid Values What It Controls
lineWidth 1 Any positive number Thickness of the stroke in pixels
lineCap 'butt' 'butt', 'round', 'square' Shape of open path endpoints
lineJoin 'miter' 'miter', 'round', 'bevel' Shape of corners between segments
miterLimit 10 Any positive number Maximum miter length before falling back to bevel
setLineDash() [] (solid) Array of numbers Dash and gap pattern for the stroke

setLineDash() enables dashed or dotted lines. You pass it an array of numbers representing alternating dash lengths and gap lengths, in pixels. If the array has an odd number of elements, the pattern is duplicated to make it even. You can also offset where the dash pattern starts along the path using ctx.lineDashOffset, which is useful for animating a "marching ants" selection effect.

// Dashed line: 10px dash, 5px gap
ctx.beginPath();
ctx.setLineDash([10, 5]);
ctx.moveTo(20, 50);
ctx.lineTo(280, 50);
ctx.strokeStyle = 'crimson';
ctx.lineWidth = 2;
ctx.stroke();

// Dotted line: 2px dash, 4px gap
ctx.beginPath();
ctx.setLineDash([2, 4]);
ctx.moveTo(20, 80);
ctx.lineTo(280, 80);
ctx.stroke();

// Return to solid lines for subsequent drawing
ctx.setLineDash([]);
// Animated marching ants selection border
let offset = 0;
function animateDash() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    ctx.beginPath();
    ctx.setLineDash([6, 3]);
    ctx.lineDashOffset = -offset;
    ctx.strokeStyle = '#000';
    ctx.lineWidth = 1;
    ctx.strokeRect(30, 30, 200, 120);  // strokeRect respects lineDash too
    offset = (offset + 1) % 9;        // 9 = sum of dash pattern (6+3)
    requestAnimationFrame(animateDash);
}
animateDash();

Together, these properties — lineWidth, lineCap, lineJoin, and setLineDash() — give you precise control over the visual quality of every stroked path. Mastering them, alongside the path construction methods, lets you produce anything from crisp geometric diagrams to expressive hand-drawn-style graphics entirely within the canvas API.

NotesStudents should practise drawing several multi-segment shapes in sequence, deliberately alternating styles, to build the muscle memory of always calling beginPath() before each new shape. A useful exercise is replicating a simple icon (such as a house or arrow) using only moveTo(), lineTo(), closePath(), fill(), and stroke(), then experimenting with every lineCap and lineJoin option to observe the visual differences firsthand.