Paths & Shapes

Draw any shape you can imagine using paths, arcs, and curves.

Lesson 2 of 10

Rectangles are useful for prototyping, but real graphics need triangles, circles, stars, and freeform shapes. This lesson introduces the path system: Canvas’s way of describing any shape you can imagine. By the end, you will draw polygons, smooth curves, and combine them into complex figures.

The “Pen” Metaphor

In Lesson 1, we drew rectangles, but the real world isn’t made of rectangles. To draw triangles, circles, arrows, speech bubbles, or any custom shape, we need paths.

Imagine you’re holding an invisible pen over a piece of paper:

Drawing with an invisible pen:

  1. Pick up the pen and place it somewhere
     --> moveTo(x, y)         "Move without drawing"

  2. Drag the pen to draw a line
     --> lineTo(x, y)         "Draw a straight line to here"

  3. Draw a curve
     --> arc(x, y, r, startAngle, endAngle)
     --> bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y)

  4. Go back to where you started
     --> closePath()           "Connect back to start"

  5. Fill in the shape or trace its outline
     --> fill() or stroke()   "Apply the ink"

  The pen REMEMBERS where it is after each command.
  Every new command starts from where the last one ended.

The key insight: path commands don’t draw anything visible. They build up an invisible outline. Only when you call fill() or stroke() does the shape actually appear on the canvas.

Why beginPath() is Critical

beginPath() starts a brand new path, throwing away whatever path was being built before. This is the single most common source of canvas bugs for beginners: forgetting beginPath().

The beginPath() Bug paused

Try this: Delete the second ctx.beginPath() call and hit Run. Both triangles turn cyan because stroke() redraws the entire accumulated path. Add it back to see the fix.

Rule of thumb: Call beginPath() every time you start a new shape. Without it, every shape you’ve drawn since the last beginPath() is still part of the current path, and fill() or stroke() will apply to ALL of them.

moveTo and lineTo: Straight Lines

moveTo(x, y) picks up the pen and places it at a new position without drawing. lineTo(x, y) draws a straight line from wherever the pen currently is to the new point.

moveTo + lineTo Basics paused

closePath: Connecting Back to Start

closePath() draws a straight line from the current pen position back to the very first point of the current path (where you called moveTo() initially). It’s how you close a shape cleanly.

With and Without closePath paused

Important note: fill() automatically closes the path for you (it has to, since you can’t fill an open shape). But stroke() does NOT auto-close. So if you’re stroking a shape and you want it closed, always call closePath() before stroke().

fill() vs stroke(): Filling vs Outlining

After building a path, you have two choices for making it visible:

  • fill() colors in the entire interior of the shape
  • stroke() draws just the outline

You can use both on the same path!

fill() vs stroke() vs Both paused

Arcs and Circles

arc(x, y, radius, startAngle, endAngle, counterclockwise) draws a circular arc (a portion of a circle, or the whole circle). Angles are in radians, not degrees.

Radians cheat sheet:

  Degrees     Radians            Position on clock
  ───────     ─────────────      ──────────────────
  0           0                  3 o'clock (right)
  90          Math.PI / 2        6 o'clock (bottom)
  180         Math.PI            9 o'clock (left)
  270         Math.PI * 1.5      12 o'clock (top)
  360         Math.PI * 2        full circle

  Formula: radians = degrees * Math.PI / 180

                  270 / PI*1.5
                       │
                       │
  180 / PI  ───────────●─────────── 0 (start)
                       │
                       │
                  90 / PI/2

  REMEMBER: 0 radians points RIGHT, not up!
  And angles go CLOCKWISE (Y is flipped in canvas).
arc(): Circles and Partial Arcs paused

Arc Direction: Clockwise vs Counterclockwise

The last parameter of arc() is counterclockwise (default: false = clockwise). This controls which way the arc sweeps from the start angle to the end angle.

Clockwise vs Counterclockwise paused

When does direction matter? For a full circle (0 to PI*2), it doesn’t matter. But for partial arcs (pie charts, gauges, pac-man), the direction determines which “slice” of the circle you get. Same start and end angle, but clockwise gives you the big slice while counterclockwise gives the small slice (or vice versa).

rect(): Rectangle as a Path

We used fillRect() and strokeRect() in Lesson 1, but there’s also rect(x, y, width, height) which adds a rectangle to the current path without drawing it immediately. This lets you combine rectangles with other path operations.

rect(): Add Rectangle to a Path paused

ellipse(): Ovals and Rotated Circles

ellipse(x, y, radiusX, radiusY, rotation, startAngle, endAngle) is like arc() but allows different horizontal and vertical radii, plus rotation. The egg example below also uses save(), translate(), scale(), and restore() to stretch a circle into an oval; these transform methods will be covered in a later lesson.

ellipse(): Beyond Circles paused

Quadratic Curves: One Control Point

quadraticCurveTo(cpx, cpy, endX, endY) draws a curve from the current pen position, bending toward a control point, and ending at the endpoint.

The rubber band analogy:

  Imagine a rubber band pinned at START and END.
  The control point is a finger pulling the band sideways.

  control point (cpx, cpy)
       ●
      / \
     /   \         The curve bends TOWARD
    /     \        the control point but
   /       \       never actually touches it
  ●─────────●      (usually).
 START      END

  The further the control point is from
  the straight line, the more the curve bends.

Want the actual math? The quadratic Bezier formula is B(t) = (1-t)^2*P0 + 2*(1-t)*t*P1 + t^2*P2. It’s just two levels of lerp (linear interpolation) stacked together. See the Math Appendix for the full derivation, interactive visualizations, and why it works.

quadraticCurveTo: One Control Point paused

Bezier Curves: Two Control Points

bezierCurveTo(cp1x, cp1y, cp2x, cp2y, endX, endY) is the more powerful cousin of quadratic curves. With two control points, you can make S-curves, loops, and the smooth curves used in fonts and vector graphics.

How two control points shape a curve:

  CP1 controls the      CP2 controls the
  START of the curve    END of the curve
      ●                     ●
     ╱                       ╲
    ╱   The curve leaves      ╲
   ╱    START heading          ╲
  ●     toward CP1, then   ────●
 START  bends to arrive       END
        from CP2's direction

  Think of it as: CP1 says "leave this way"
                  CP2 says "arrive this way"

  S-curve (control points on opposite sides):
   ● CP1
    ╲
     ╲    ╱────╲
  ●───╲──╱      ╲──●
 START            END
                ╱
               ╱
              ● CP2

The cubic formula: B(t) = (1-t)^3*P0 + 3*(1-t)^2*t*P1 + 3*(1-t)*t^2*P2 + t^3*P3. Three levels of lerp. The Math Appendix derives this step by step and shows why the coefficients 1, 3, 3, 1 come from Pascal’s triangle.

bezierCurveTo: Two Control Points paused

Fill Rules: nonzero vs evenodd

When paths overlap or have holes, how does Canvas decide what’s “inside” the shape? That’s the fill rule. You pass it to fill().

How a Star Is Drawn: Skip-Connecting Vertices paused

The 5 points sit on a circle like a pentagon. But instead of connecting 0→1→2→3→4 (which would draw a pentagon), the star connects 0→2→4→1→3 (skipping one each time). The lines cross over each other, creating a center region where the path overlaps. That overlap is what makes the fill rule matter:

nonzero vs evenodd Fill Rules paused

When to use each:

  nonzero (default):
  - Fills everything enclosed by the path
  - Use for simple shapes, most of the time

  evenodd:
  - Counts how many times a ray crosses the path
  - Odd crossings = inside (filled)
  - Even crossings = outside (not filled)
  - Use for shapes with holes (donuts, stars, etc.)

Practical Shape: Rounded Rectangle

Rounded rectangles are everywhere in UI design (buttons, cards, tooltips). Modern browsers support ctx.roundRect(x, y, width, height, radii) (since 2023), but building one yourself from lines and arcs is great practice for learning path primitives.

Building a Rounded Rectangle paused

Practical Shape: Speech Bubble

A speech bubble is a rounded rectangle with a triangular tail, combining lines, arcs, and curves in one path.

Speech Bubble Shape paused

Practical Shape: Arrows

Arrows are useful for diagrams, flowcharts, and UI. Here’s how to build them from paths.

Drawing Arrows paused

Practical Shape: Regular Polygons

With a simple loop, you can draw any regular polygon (triangle, pentagon, hexagon, octagon…) by computing points around a circle.

Regular Polygons from a Function paused

Notice: As the number of sides increases, the polygon starts looking like a circle! In fact, arc() is just a polygon with so many sides you can’t see the edges. This is how computers draw circles; they approximate them with many tiny straight lines.

Practical Shape: Gear

A gear combines the polygon idea with alternating inner and outer radii, the same approach as the star in the fill rules demo, but with more teeth.

Gear Shape paused

How does alternating between two radii create teeth? The idea: place 2 x teeth points around a circle. Even points sit on the outer radius (tooth tips), odd points sit on the inner radius (tooth valleys). Connect them in order and you get a gear.

How a Gear Is Drawn: Alternating Radii paused

The gear algorithm:

  teeth = 12  -->  points = 24
  
  Point 0  (even) --> outer radius --> tooth TIP
  Point 1  (odd)  --> inner radius --> tooth VALLEY
  Point 2  (even) --> outer radius --> tooth TIP
  Point 3  (odd)  --> inner radius --> tooth VALLEY
  ...repeat...
  
  Each pair (tip + valley) = one tooth.
  Connect all points in order --> gear shape.
  
  Fewer teeth = bigger teeth (8-tooth vs 12-tooth).
  Gap between radii = tooth height.

Preview: isPointInPath() for Hit Testing

Canvas doesn’t have built-in “click on a shape” like DOM elements. But isPointInPath(x, y) tells you whether a point is inside the current path. This is the foundation for making shapes interactive (covered fully in Lesson 7).

isPointInPath(): Click Detection Preview paused

How it works: After building a path (but before or after drawing it), call ctx.isPointInPath(x, y). It returns true if the point is inside the path, false otherwise. This works for ANY shape: circles, polygons, custom paths, even bezier curves!

Putting It All Together: A Complete Scene

Let’s combine everything from this lesson into a single drawing that uses lines, arcs, curves, and custom shapes.

Paths in Action: Mountains and Lake paused

Challenges

Challenge 1: Draw a House with Paths

  Use paths (not fillRect) to draw:
  - A triangular roof (moveTo + lineTo + closePath + fill)
  - A rectangular wall (rect or 4 lineTo calls)
  - A circular window (arc)
  - An arched doorway (lineTo + arc)

  This practices combining different path types.

Challenge 2: Draw a Pie Chart

  Given data like: var data = [30, 20, 25, 15, 10];
  Draw a pie chart:

  - Each slice is an arc (use moveTo to center, then arc)
  - Calculate angles: slice / total * Math.PI * 2
  - Use closePath() + fill() for each slice
  - Different color per slice

  Hint:
    var total = 100;
    var startAngle = 0;
    data.forEach(function(value) {
      var sliceAngle = (value / total) * Math.PI * 2;
      ctx.beginPath();
      ctx.moveTo(cx, cy);
      ctx.arc(cx, cy, radius, startAngle, startAngle + sliceAngle);
      ctx.closePath();
      ctx.fill();
      startAngle += sliceAngle;
    });

Challenge 3: Draw Your Initials

  Use lineTo and bezierCurveTo to draw your initials
  as custom letter shapes (not ctx.fillText).

  For example, the letter "S" could be two arcs:
    ctx.beginPath();
    ctx.arc(x, y1, r, Math.PI, 0);       // top curve
    ctx.arc(x, y2, r, 0, Math.PI);       // bottom curve (reversed)
    ctx.stroke();

  For straight letters (L, T, H), use lineTo.
  For curved letters (S, C, O), use arc or bezierCurveTo.

What you learned in this lesson:

  Concept                How to do it
  ────────────────────── ─────────────────────────────────────
  Start a new path       ctx.beginPath()
  Move pen (no drawing)  ctx.moveTo(x, y)
  Straight line          ctx.lineTo(x, y)
  Close the shape        ctx.closePath()
  Fill the shape         ctx.fill()  or  ctx.fill('evenodd')
  Outline the shape      ctx.stroke()
  Circle/arc             ctx.arc(x, y, r, start, end, ccw?)
  Oval                   ctx.ellipse(x, y, rx, ry, rot, s, e)
  Rectangle path         ctx.rect(x, y, w, h)
  Simple curve           ctx.quadraticCurveTo(cpx, cpy, x, y)
  Complex curve          ctx.bezierCurveTo(c1x,c1y,c2x,c2y,x,y)
  Hit testing            ctx.isPointInPath(x, y)

  Key ideas:
  - ALWAYS call beginPath() before a new shape
  - Path commands are invisible: fill()/stroke() makes them real
  - fill() auto-closes paths; stroke() does not
  - Angles are in RADIANS (PI = 180 degrees)
  - Combine line, arc, and curve calls in one path for complex shapes