Skip to main content

Path2D

The Path2D class allows you to create paths independent of a given Canvas or graphics context. These paths can be modified over time and drawn repeatedly (potentially on multiple canvases). Path2D objects can also be used as lineDashMarkers or as the repeating pattern in a CanvasTexture.

Line SegmentsShapesBoolean Ops 🧪Filters 🧪Geometry 🧪
d 🧪addPath()complement()interpolate()bounds
moveTo()arc()difference()jitter()edges
lineTo()arcTo()intersect()round()contains()
bezierCurveTo()ellipse()union()simplify()points()
conicCurveTo() 🧪rect()xor()trim()offset()
quadraticCurveTo()roundRect()unwind()transform()
closePath()

Creating Path2D objects

Its constructor can be called without any arguments to create a new, empty path object. It can also accept a string using SVG syntax or a reference to an existing Path2D object (which it will return a clone of):

// three identical (but independent) paths
let p1 = new Path2D("M 10,10 h 100 v 100 h -100 Z")
let p2 = new Path2D(p1)
let p3 = new Path2D()
p3.rect(10, 10, 100, 100)

Drawing paths

A canvas’s context always contains an implicit ‘current’ bézier path which is updated by commands like lineTo() and arcTo() and is drawn to the canvas by calling fill(), stroke(), or clip() without any arguments (aside from an optional winding rule). If you start creating a second path by calling beginPath() the context discards the prior path, forcing you to recreate it by hand if you need it again later.

You can then use these objects by passing them as the first argument to the context’s fill(), stroke(), and clip() methods (along with an optional second argument specifying the winding rule).


Properties

.bounds

In the browser, Path2D objects offer very little in the way of introspection—they are mostly-opaque recorders of drawing commands that can be ‘played back’ later on. Skia Canvas offers some additional transparency by allowing you to measure the total amount of space the lines will occupy (though you’ll need to account for the current lineWidth if you plan to draw the path with stroke()).

The .bounds property returns an object defining the minimal rectangle containing the path:

{top, left, bottom, right, width, height}

.d

Contains a string describing the path’s edges using SVG syntax. This property is both readable and writeable (and can be appended to using the += operator).

.edges

Returns an array containing each path segment that has been added to the path so far. Each element of the list is an array of the form ["verb", ...points], mirroring the calling conventions of both Path2D and the rendering context. As a result, the edges may be used to ‘replay’ a sequence of commands such as:

let original = new Path2D()
// ... add some contours to the path

// apply the original path’s edges to a new Path2D
let clone = new Path2D()
for (const [verb, ...pts] of original.edges){
clone[verb](...pts)
}

// or use the original path’s edges to draw directly to the context
for (const [verb, ...pts] of original.edges){
ctx[verb](...pts)
}

The array is not a verbtaim transcript of the drawing commands that have been called since some commands (e.g., arc()) will be converted into an equivalent sequence of bézier curves. The full range of verbs and numbers of point arguments is as follows:

[
["moveTo", x, y],
["lineTo", x, y],
["quadraticCurveTo", cpx, cpy, x, y],
["bezierCurveTo", cp1x, cp1y, cp2x, cp2y, x, y],
["conicCurveTo", cpx, cpy, x, y, weight],
["closePath"]
]

Methods

contains()

contains(x, y)
returns → boolean

Returns true if the point (x, y) is either inside the path or intersects one of its contours.

complement(), difference(), intersect(), union(), and xor()

complement(path)
difference(path)
intersect(path)
union(path)
xor(path)
returns → Path2D

In addition to creating Path2D objects through the constructor, you can use pairs of existing paths in combination to generate new paths based on their degree of overlap. Based on the method you choose, a different boolean relationship will be used to construct the new path. In all the following examples we’ll be starting off with a pair of overlapping shapes:

let oval = new Path2D()
oval.arc(100, 100, 100, 0, 2*Math.PI)

let rect = new Path2D()
rect.rect(0, 100, 100, 100)

layered paths

We can then create a new path by using one of the boolean operations such as:

let knockout = rect.complement(oval),
overlap = rect.intersect(oval),
footprint = rect.union(oval),
...

different combinations

Note that the xor operator is liable to create a path with lines that cross over one another so you’ll get different results when filling it using the "evenodd" winding rule (as shown above) than with "nonzero" (the canvas default).

interpolate()

interpolate(otherPath, weight)
returns → Path2D

When two similar paths share the same sequence of ‘verbs’ and differ only in the point arguments passed to them, the interpolate() method can combine them in different proportions to create a new path. The weight argument controls whether the resulting path resembles the original (at 0.0), the otherPath (at 1.0), or something in between.

let start = new Path2D()
start.moveTo(-200, 100)
start.bezierCurveTo(-300, 100, -200, 200, -300, 200)
start.bezierCurveTo(-200, 200, -300, 300, -200, 300)

let end = new Path2D()
end.moveTo(200, 100)
end.bezierCurveTo(300, 100, 200, 200, 300, 200)
end.bezierCurveTo(200, 200, 300, 300, 200, 300)

let left = start.interpolate(end, .25),
mean = start.interpolate(end, .5),
right = start.interpolate(end, .75)

merging similar paths

jitter()

jitter(segmentLength, amount, seed=0)
returns → Path2D

The jitter() method will return a new Path2D object obtained by breaking the original path into segments of a given length then applying random offsets to the resulting points. Though the modifications are random, they will be consistent between runs based on the specified seed. Try passing different integer values for the seed until you get results that you like.

let cube = new Path2D()
cube.rect(100, 100, 100, 100)
cube.rect(150, 50, 100, 100)
cube.moveTo(100, 100)
cube.lineTo(150, 50)
cube.moveTo(200, 100)
cube.lineTo(250, 50)
cube.moveTo(200, 200)
cube.lineTo(250, 150)

let jagged = cube.jitter(1, 2),
reseed = cube.jitter(1, 2, 1337),
sketchy = cube.jitter(10, 1)

xkcd-style

offset()

offset(dx, dy)
returns → Path2D

Returns a copy of the path whose points have been shifted horizontally by dx and vertically by dy.

points()

points(step=1)
returns → [[x1, y1], [x2,y2], ...]

The points() method breaks a path into evenly-sized steps and returns the (x, y) positions of the resulting vertices. The step argument determines the amount of distance between neighboring points and defaults to 1 px if omitted.

let path = new Path2D()
path.arc(100, 100, 50, 0, 2*Math.PI)
path.rect(100, 50, 50, 50)
path = path.simplify()

for (const [x, y] of path.points(10)){
ctx.fillRect(x, y, 3, 3)
}

sampling points from a path

round()

round(radius)
returns → Path2D

Calling round() will return a new Path2D derived from the original path whose corners have been rounded off to the specified radius.

let spikes = new Path2D()
spikes.moveTo(50, 225)
spikes.lineTo(100, 25)
spikes.lineTo(150, 225)
spikes.lineTo(200, 25)
spikes.lineTo(250, 225)
spikes.lineTo(300, 25)

let snake = spikes.round(80)

no sharp edges

simplify()

simplify(rule="nonzero")
returns → Path2D

In cases where the contours of a single path overlap one another, it’s often useful to have a way of effectively applying a union operation within the path itself. The simplify method traces the path and returns a new copy that removes any overlapping segments. When called with no arguments it defaults to the "nonzero" winding rule, but can also be called with "evenodd" to preserve overlap regions while still removing edge-crossings.

let cross = new Path2D(`
M 10,50 h 100 v 20 h -100 Z
M 50,10 h 20 v 100 h -20 Z
`)
let uncrossed = cross.simplify()

different combinations

transform()

transform(...matrix)
returns → Path2D

Returns a new copy of the path whose points have been modified by the specified transform matrix. The matrix can be passed as a DOMMatrix object, a CSS transform string (e.g, "rotate(20deg)"), or 6 individual numbers (see the Context's setTransform() documentation for details). The original path remains unmodified.

trim()

trim(start, end, inverted)
returns → Path2D

The trim() method returns a new Path2D which contains only a portion of the original path. The start and end arguments specify percentages of the original contour as numbers between 0 and 1.0. If both arguments are provided, the new path will be a continuous contour connecting those endpoints. If the inverted argument is set to true, the new path will contain everything from the original except the region between the specified endpoints.

Passing a single positive number implicitly sets the starting point to 0.0 and uses the supplied argument as the end. Passing a negative value sets the ending point to 1.0 and uses the argument as the start value. In either case, you can include inverted as the second argument to flip the selected contour.

let orig = new Path2D()
orig.arc(100, 100, 50, Math.PI, 0)

let middle = orig.trim(.25, .75),
endpoints = orig.trim(.25, .75, true),
left = orig.trim(.25),
right = orig.trim(-.25)

trimmed subpaths

unwind()

unwind()
returns → Path2D

The unwind() method interprets the current path using the "evenodd" winding rule then returns a new path that covers an equivalent area when filled using the "nonzero" rule (i.e., the default behavior of the context’s fill() method).

This conversion can be useful in situations where a single path contains multiple, overlapping contours and the resulting shape depends on the nesting-depth and direction of the contours.

let orig = new Path2D(`
M 0 0 h 100 v 100 h -100 Z
M 50 30 l 20 20 l -20 20 l -20 -20 Z
`)

let unwound = orig.unwind()

convert winding rule subpaths