Skip to content
JavaScript js canvas 5 min read

Drawing Paths & Lines

Rectangles and circles cover the easy shapes, but real drawing happens with paths. A path is a list of subpaths made up of line segments and curves that you describe step by step, then either outline (stroke) or fill (fill). Mastering the path API is what lets you draw triangles, stars, polygons, charts, and any arbitrary outline on a <canvas>. The model is simple once it clicks: you begin a path, move a virtual “pen” around, then commit the result to pixels.

The path lifecycle

Every custom shape follows the same four-step rhythm. First call beginPath() to reset the current path and start fresh. Then move the pen and add segments with moveTo() and lineTo(). Optionally call closePath() to draw a final segment back to the start. Finally, render with stroke() (outline) or fill() (solid interior).

MethodWhat it does
beginPath()Clears any existing path so new commands start clean
moveTo(x, y)Lifts the pen and moves it to (x, y) without drawing
lineTo(x, y)Draws a straight line from the current point to (x, y)
closePath()Connects the current point back to the subpath’s start
stroke()Outlines the current path using the current stroke style
fill()Fills the interior of the current path

The pen position is stateful: each lineTo() continues from wherever the last command left it. moveTo() is how you start a new disconnected subpath within the same beginPath().

const ctx = canvas.getContext("2d");

ctx.beginPath();
ctx.moveTo(20, 20);   // start point
ctx.lineTo(120, 20);  // top edge
ctx.lineTo(70, 100);  // down to the apex
ctx.closePath();      // back to (20, 20)
ctx.stroke();         // draw the outline

Always call beginPath() first. If you skip it, the new shape’s commands are appended to whatever was already on the canvas’s current path, and your next stroke()/fill() will redraw the old segments too. This is the single most common canvas path bug.

Stroke vs. fill (and order matters)

You can call both stroke() and fill() on the same path. Because filling paints the interior and stroking paints the outline, the order changes the result: filling first, then stroking, gives a crisp border that sits fully outside the fill. Stroking first lets the fill cover the inner half of the line.

ctx.beginPath();
ctx.moveTo(50, 50);
ctx.lineTo(150, 50);
ctx.lineTo(100, 130);
ctx.closePath();

ctx.fillStyle = "#38bdf8";
ctx.fill();              // fill the interior first

ctx.strokeStyle = "#0f172a";
ctx.lineWidth = 4;
ctx.stroke();           // then the crisp border on top

Line caps and joins

When you stroke a path, two properties control how the line’s geometry looks at its ends and corners. lineCap styles the endpoints of open subpaths, and lineJoin styles the corners where two segments meet.

PropertyValuesEffect
lineCap"butt" (default), "round", "square"Shape at the start/end of a line
lineJoin"miter" (default), "round", "bevel"Shape at corners between segments
miterLimitnumber (default 10)Caps how far a sharp miter can extend before it falls back to a bevel

"butt" ends the line flush at the coordinate; "round" and "square" extend it by half the line width. For sharp angles, a "miter" join can spike out dramatically — miterLimit is the safety valve that converts overly long spikes into bevels.

Dashed lines with setLineDash

By default strokes are solid. Call setLineDash([...]) with an array of on/off lengths to create dashes. [10, 5] means a 10px dash followed by a 5px gap, repeating. Use lineDashOffset to shift the pattern — animating it produces the classic “marching ants” selection effect.

ctx.setLineDash([12, 6]);     // 12px dash, 6px gap
ctx.lineDashOffset = 0;

ctx.beginPath();
ctx.moveTo(20, 60);
ctx.lineTo(280, 60);
ctx.stroke();

ctx.setLineDash([]);          // reset to a solid line

Pass an empty array setLineDash([]) to return to solid strokes. The dash setting is part of the canvas state, so it persists across draws until you reset it (or restore a saved state).

Drawing a polygon programmatically

Hard-coding lineTo() calls is fine for a triangle, but regular polygons are best generated with a loop and a little trigonometry. Spread n vertices evenly around a circle by stepping the angle in increments of 2π / n.

function polygon(ctx, cx, cy, radius, sides, rotation = -Math.PI / 2) {
  ctx.beginPath();
  for (let i = 0; i < sides; i++) {
    const angle = rotation + (i * 2 * Math.PI) / sides;
    const x = cx + radius * Math.cos(angle);
    const y = cy + radius * Math.sin(angle);
    if (i === 0) ctx.moveTo(x, y);
    else ctx.lineTo(x, y);
  }
  ctx.closePath();
}

polygon(ctx, 100, 100, 60, 5); // a pentagon
ctx.fillStyle = "#a78bfa";
ctx.fill();

Interactive demo: a triangle and a star

The pen below draws a filled triangle and a five-pointed star. A star alternates between an outer radius (the points) and an inner radius (the valleys), so it uses twice as many vertices as it has points.

<canvas id="c" width="360" height="200" style="background:#0f172a;border-radius:8px"></canvas>
<script>
  const ctx = document.getElementById("c").getContext("2d");

  // --- Triangle ---
  ctx.beginPath();
  ctx.moveTo(80, 40);
  ctx.lineTo(140, 150);
  ctx.lineTo(20, 150);
  ctx.closePath();
  ctx.fillStyle = "#38bdf8";
  ctx.fill();
  ctx.lineWidth = 4;
  ctx.lineJoin = "round";
  ctx.strokeStyle = "#e2e8f0";
  ctx.stroke();

  // --- Five-pointed star ---
  function star(cx, cy, points, outer, inner) {
    ctx.beginPath();
    for (let i = 0; i < points * 2; i++) {
      const r = i % 2 === 0 ? outer : inner;
      const angle = -Math.PI / 2 + (i * Math.PI) / points;
      const x = cx + r * Math.cos(angle);
      const y = cy + r * Math.sin(angle);
      i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
    }
    ctx.closePath();
  }

  star(270, 95, 5, 60, 24);
  ctx.fillStyle = "#fbbf24";
  ctx.fill();
  ctx.strokeStyle = "#f59e0b";
  ctx.stroke();
</script>

Marching ants: animating a dashed outline

Animating lineDashOffset on each frame creates a moving dashed border — perfect for highlighting a selection.

<canvas id="ants" width="320" height="180" style="background:#020617;border-radius:8px"></canvas>
<script>
  const ctx = document.getElementById("ants").getContext("2d");
  let offset = 0;

  function frame() {
    ctx.clearRect(0, 0, 320, 180);
    ctx.setLineDash([14, 8]);
    ctx.lineDashOffset = -offset;
    ctx.strokeStyle = "#22d3ee";
    ctx.lineWidth = 3;
    ctx.strokeRect(40, 30, 240, 120);

    offset = (offset + 0.6) % 22; // 14 + 8 = one full pattern
    requestAnimationFrame(frame);
  }
  frame();
</script>

Best Practices

  • Call beginPath() at the start of every shape to avoid re-drawing stale segments.
  • Use closePath() for filled shapes so the outline seals cleanly — fill() auto-closes, but stroke() does not.
  • Set strokeStyle, fillStyle, lineWidth, and dash settings before the stroke()/fill() call that uses them.
  • Reset dashes with setLineDash([]) once you’re done, since the setting persists across draws.
  • Generate regular polygons and stars with a loop and Math.cos/Math.sin rather than hand-typing coordinates.
  • Offset coordinates by 0.5 (e.g. moveTo(20.5, 20.5)) when you need pixel-crisp 1px horizontal/vertical lines.
  • Wrap style changes in save()/restore() when a helper function shouldn’t leak state to the rest of your drawing.
Last updated June 1, 2026
Was this helpful?