Skip to content

Canvas Drawing

A UI principle for coding agents. Also covers drawImage, Path2D, canvas gradients, 2d context, fillRect, beginPath, and 11 more.

Show all 17 aliases

drawImage, Path2D, canvas gradients, 2d context, fillRect, beginPath, canvas paths, canvas sprites, fillText, measureText, canvas text, globalAlpha, globalCompositeOperation, destination-out, conic gradient, data URI sprite, canvas pointer coordinates

Core Philosophy

  • The canvas element is a blank bitmap. Every mark on it comes from the 2D context returned by canvas.getContext("2d"), which holds both the drawing methods and the current style state
  • Style properties like fillStyle, strokeStyle and lineWidth are context state, not arguments. A command uses whatever the context holds at the moment it runs, so set styles before painting, and remember they persist into every later command until changed
  • Drawing is two-phase: describe a geometry with path commands, then rasterize it with fill() or stroke(). The description alone paints nothing
  • There is no z-index. Later commands paint over earlier ones, so layering is controlled by call order
  • There is no layout engine either. Nothing centres, wraps, flows or reserves space. Every coordinate is arithmetic you write
  • This doc covers what to draw. The frame loop, device-pixel-ratio setup and teardown that make it move live in canvas-animation, the motion maths in canvas-motion-model, and per-cell fields, grid hit-testing and discrete simulation loops in canvas-fields-and-grids

Rectangles

  • fillRect(x, y, w, h) and strokeRect(x, y, w, h) paint immediately, no path needed. They are the shortcut tier of the API
  • clearRect(x, y, w, h) erases pixels back to transparent. It does not paint a background color over them
  • fillRect() reads the current fillStyle, and strokeRect() reads strokeStyle and lineWidth. clearRect() ignores all three and always clears to transparent
ctx.fillStyle = "oklch(0.7 0.15 250)";
ctx.fillRect(20, 20, 120, 80);

ctx.strokeStyle = "oklch(0.3 0 0)";
ctx.lineWidth = 4;
ctx.strokeRect(160, 20, 120, 80);

Paths

  • Line and curve geometry goes through the path API (text, images and pixels have their own calls): beginPath(), then moveTo / lineTo and friends, then fill() or stroke()
  • beginPath() discards the previous geometry and starts fresh. Without it, a second stroke() repaints the earlier segments too, in the new color. Two shapes in two colors means two begin-stroke cycles
  • Call beginPath() before the first shape as well, not only between shapes. It is a no-op on a fresh context, and it makes the drawing block relocatable: since call order is the only layering control, blocks get moved around constantly, and one that assumes an empty path breaks the moment it lands after another shape
  • closePath() is not the counterpart of beginPath(). It draws a straight line back to the subpath's start point, exactly like the Z command in an SVG path. Call it to close an outline, omit it for open strokes like a check mark
  • lineCap: "round" and lineJoin: "round" are what make stroked paths feel finished instead of clipped
ctx.beginPath();
ctx.moveTo(40, 100);
ctx.lineTo(80, 140);
ctx.lineTo(160, 40);
ctx.lineWidth = 8;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.strokeStyle = "oklch(0.65 0.2 150)";
ctx.stroke();

Circles, arcs and curves

  • arc(cx, cy, radius, startAngle, endAngle) sweeps around a center point. Angles are radians, measured from the 3 o'clock position. The full-circle idiom is arc(cx, cy, r, 0, Math.PI * 2)
  • The API has no circle() shortcut, so wrap the idiom in a one-line helper if circles are common in the drawing
  • ellipse(cx, cy, rx, ry, rotation, startAngle, endAngle) is the same idea with independent radii and a rotation, all in radians
  • arcTo(x1, y1, x2, y2, radius) fits an arc against two control lines, in principle for rounding a corner between two segments mid-path. In practice it is rarely the right tool: it behaves nothing like SVG's A command, it takes control points rather than an endpoint and sweep, and at larger radii the arc moves away from where the control points suggest it should sit. Reach for quadraticCurveTo for a rounded corner, or author the shape in SVG and draw it through Path2D
  • quadraticCurveTo(cpx, cpy, x, y) takes one control point; bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y) takes two, for the cubic curve. If a shape needs many of these, it is usually a sign the shape belongs in SVG and canvas should load it as an image instead (below)

Path2D

  • new Path2D() returns a standalone path object with the same path-building methods as the context (moveTo, lineTo, arc and friends). It paints nothing itself; pass it to ctx.fill(path) or ctx.stroke(path)
  • The constructor also accepts an SVG path string: new Path2D("M 10 10 L 90 90 A 40 40 0 1 1 90 10 Z"). Any path authored in a vector tool can be painted directly
  • Reach for it when the geometry is complex and reused. That is the case MDN describes it for: recording a path once during setup and replaying it every frame, instead of re-issuing dozens of commands per frame
  • Do not reach for it as a blanket speed-up on simple shapes. Wrapping a circle or a two-segment line in a path object adds an allocation and an indirection for no reuse, which is cost without the benefit the pattern exists for
  • Neither direction is safe to assume, and the balance shifts with the browser and the shape. The only way to know for your case is to measure it, on real low-end hardware, with your actual shape count

Drawing images

  • drawImage has three signatures: place at natural size (img, dx, dy), place and scale (img, dx, dy, dw, dh), or crop a source region and scale it (img, sx, sy, sw, sh, dx, dy, dw, dh). The nine-argument form is how sprite sheets work
  • Drawing an image that has not loaded yet paints nothing, and drawing a broken one throws an InvalidStateError
  • How much that matters depends on the context. For a single one-shot paint it is fatal, because the one draw call you make is the blank one, so wait for the image
  • Inside a running animation loop it is not. The loop keeps calling drawImage every frame, so the sprite is simply absent for the first frame or two and present from then on. The load listener is optional there, and adding one costs more code than it saves
  • The modern wait is await img.decode(), with a rejection handler for images that fail to load; the older one is the load event. With the event, attach the listener before assigning src, or a cached image can fire load before the listener exists and the canvas stays blank. That ordering rule applies to the one-shot case, which is exactly the case where a repeat visitor with a warm cache sees nothing
async function drawSprite(ctx, spriteUrl) {
  const img = new Image();
  img.src = spriteUrl;

  try {
    await img.decode();
  } catch {
    return; // broken or missing sprite, skip the draw rather than killing the frame
  }

  ctx.drawImage(img, 0, 0, 64, 64);
}
  • Keep the wait inside a function. A failed decode returns early from that function and the caller carries on, so one broken sprite costs you a sprite rather than the whole frame
  • Painting pre-made images is often cheaper and simpler than rebuilding a shape from path commands every frame. A particle system that stamps a few small sprites scales further than one that strokes each particle from scratch
  • The browser smooths images when it scales them up, which softens pixel art and any deliberately low-resolution sprite. Two different scalings can blur it, and each has its own lever. Reaching for the wrong one leaves the blur exactly where it was
  • ctx.imageSmoothingEnabled = false turns off the interpolation drawImage applies when the destination size differs from the source size. That is the scaling inside the canvas, and it is the lever for the call above, which paints the sprite into a fixed 64 by 64 box whatever its natural size. MDN calls this out as the pixel-art property: with smoothing on, the default resizing algorithm blurs the pixels
  • It is context state, not a per-call argument, and assigning the canvas width or height resets the whole context to its defaults, smoothing included. So set it after every resize, not once at startup
  • image-rendering: pixelated in CSS is a different step. It governs how the browser scales the canvas element's finished buffer into its CSS box, which is the blur you get when the buffer is smaller than the box. It has no say in how drawImage interpolated the pixels already sitting in that buffer
  • Pixel art usually wants both: draw the sprite with smoothing off, then keep the upscaled element crisp with the CSS property

Sprites with no network round trip

  • An effect that must fire on the very first interaction cannot wait for a request. If the image loads on click, the first click has nothing to paint
  • Encode the image as a base64 data URI and assign it straight to img.src. The bytes ship inside the JavaScript bundle, so the pixels are available as soon as the module runs
  • The payload below is a real, complete 1x1 PNG, kept that small so the snippet runs as written. A real sprite is the same string, longer
const SPRITE_BASE64 =
  "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";

const img = new Image();
img.src = `data:image/png;base64,${SPRITE_BASE64}`;
  • This is only viable for tiny images. A photograph encodes to an enormous string that you then ship to every visitor whether the effect fires or not. Confetti pieces and small sprite sheets are the right size; anything larger belongs on the network
  • A content security policy can block img-src data:, in which case the image silently fails to paint. The fallback is to decode the same SPRITE_BASE64 payload to bytes yourself, wrap them in a blob, and build an image bitmap
function base64ToBlob(base64, mimeType) {
  const binary = atob(base64);
  const bytes = new Uint8Array(binary.length);

  for (let i = 0; i < binary.length; i++) {
    bytes[i] = binary.charCodeAt(i);
  }

  return new Blob([bytes], { type: mimeType });
}

const bitmap = await createImageBitmap(
  base64ToBlob(SPRITE_BASE64, "image/png"),
);
ctx.drawImage(bitmap, 0, 0);
  • createImageBitmap returns a decoded bitmap that drawImage accepts directly, so there is no <img> and no load event in this path

SVG as an image source

  • An SVG string can be turned into a drawable image, giving crisp vector sprites inside the raster canvas
  • Data URI route: "data:image/svg+xml," + encodeURIComponent(svgString). Blob route: URL.createObjectURL(new Blob([svgString], { type: "image/svg+xml" })), revoked after decode
  • The SVG markup must include xmlns="http://www.w3.org/2000/svg". Without it the browser cannot decode the image: decode() rejects, and drawing it either paints nothing or throws on the broken image
  • Because the browser rasterizes the SVG at draw time, redraw it at the size you need rather than scaling up a small render

Gradients

  • Canvas has three: createLinearGradient(x0, y0, x1, y1), createRadialGradient(x0, y0, r0, x1, y1, r1) and createConicGradient(startAngle, x, y), which sweeps colour around a centre point and is what you want for a dial, a loading sweep or an angular sheen
  • All three return gradient objects; add stops with addColorStop(offset, color) where offset runs 0 to 1. Assign the object to fillStyle or strokeStyle
  • Gradient coordinates are canvas coordinates, not shape-relative. A gradient positioned for one shape will not follow a shape drawn elsewhere
  • Hoist every static gradient into setup and reuse the object across frames. A gradient whose geometry and stops never change has no reason to be rebuilt sixty times a second
  • Rebuild only the gradients that actually change. An animated gradient, a conic sweep that rotates or a radial glow that tracks a moving particle, is a legitimate technique and its object genuinely has to be recreated each frame
  • The rule is not "never allocate in the loop", it is "allocate only what changed". Rebuilding one moving gradient per frame is fine; rebuilding a fixed background gradient per frame is waste

Text

  • fillText(text, x, y) paints filled glyphs, strokeText(text, x, y) paints their outlines. Both read font, which takes the same shorthand as the CSS property, for example "bold 24px system-ui"
  • textAlign and textBaseline decide what the x and y actually mean. They move the anchor, they do not move the text within a box, because there is no box. textAlign: "center" with textBaseline: "middle" puts the given point at the centre of the string, which is what you want for a label on a shape
ctx.font = "bold 24px system-ui";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillStyle = "oklch(0.75 0.16 85)";
ctx.fillText("48%", cx, cy);
  • measureText(text) returns a metrics object whose width is the rendered width in the current font. Use it to size a background chip behind a label, to right-align by hand, or to decide where to break a string yourself
  • Two hard limits, and neither has a workaround:
    • There is no line wrapping at all. The string is painted on one line however long it is, and off the edge of the canvas if it does not fit. Every break is a separate fillText call at a y offset you compute. fillText does take an optional max width, but that condenses or shrinks the text to fit, it does not wrap it
    • The result is pixels. There is no text node, so a screen reader has nothing to announce and the text cannot be selected, searched or translated. That is not a bug you can patch; it is what canvas is. If the words matter, they belong in the DOM or in SVG
  • Canvas text is also one of the more expensive draw calls, which is another reason a label-heavy graphic wants SVG

Opacity, erasing and compositing

  • globalAlpha is the per-shape opacity dial. Set it before a draw call and every subsequent command paints at that alpha, including images and text, until you set it back
ctx.globalAlpha = particle.opacity;
ctx.drawImage(sprite, particle.x, particle.y, SIZE, SIZE);
ctx.globalAlpha = 1;
  • Prefer globalAlpha over baking the alpha into every colour string when the opacity is animated. One number changes instead of a template literal being rebuilt per particle per frame, and it applies to sprites, which a fillStyle alpha cannot
  • It is part of the saved drawing state, so save() and restore() will put it back for you if you are already transforming the context
  • clearRect only erases rectangles. To erase an arbitrary shape, switch the composite operation to destination-out, draw the shape, then switch back
ctx.globalCompositeOperation = "destination-out";
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
ctx.globalCompositeOperation = "source-over";
  • In destination-out mode the shape's colour is irrelevant, only its coverage matters. It punches a transparent hole rather than painting a background colour over the pixels
  • Resetting to source-over is not optional. The property is context state like any other, so leaving it set means the next unrelated draw call erases instead of painting, which shows up as a shape that mysteriously deletes the scene
  • This is how a scratch-off reveal, an eraser tool or a soft spotlight mask is built. Inside a normal animation loop it is rarely needed, because clearing everything and repainting is simpler

Positioning without a layout engine

  • Canvas has no layout. A grid of shapes is arithmetic: map each row and column index onto a coordinate range across the canvas, then draw each shape centred on the result
  • Leave padding of at least half the shape size at every edge. Positions are centres, so a shape placed at x equal to 0 has half its width outside the canvas and comes out visibly cropped. Map the indices into the range from half-size to width minus half-size, not 0 to width
  • For a stroked shape, half the line width counts too. A thick round-capped stroke reaches further than its geometry suggests
  • Per-cell field composition, hit-testing a grid against the pointer and discrete simulation loops are covered in canvas-fields-and-grids

Pointer coordinates must be made canvas-relative

  • A pointer event reports viewport coordinates. Canvas drawing coordinates start at the canvas's top-left corner. Using event.clientX directly works only when the canvas happens to sit flush against the viewport corner
  • Subtract the canvas bounding rect
canvas.addEventListener("pointermove", (event) => {
  const box = canvas.getBoundingClientRect();

  pointer.x = event.clientX - box.left;
  pointer.y = event.clientY - box.top;
});
  • This is the silent offset bug in every cursor-driven canvas effect. Nothing throws, nothing looks broken in isolation; the effect just tracks a point that is consistently wrong by however far the canvas sits from the corner, and it looks correct on the one full-bleed demo page where the offset is zero
  • The rect is measured in CSS pixels, which is the same space the drawing code uses once the context has been scaled by devicePixelRatio, so no further conversion is needed
  • getBoundingClientRect can force layout, so do not call it on every event in a hot handler. Cache it and refresh on resize and scroll, as in pointer-interactions

Technique: connected-dot networks

  • The plexus look: a field of drifting points, with a line drawn between every pair that sits closer than a threshold, the line's alpha fading to zero as the distance approaches that threshold
  • The fade is what sells it. Lines dissolve as points separate instead of popping in and out
for (let i = 0; i < points.length; i++) {
  for (let j = i + 1; j < points.length; j++) {
    const d = Math.hypot(points[i].x - points[j].x, points[i].y - points[j].y);
    if (d > MAX_DIST) continue;
    ctx.strokeStyle = `oklch(1 0 0 / ${1 - d / MAX_DIST})`;
    ctx.beginPath();
    ctx.moveTo(points[i].x, points[i].y);
    ctx.lineTo(points[j].x, points[j].y);
    ctx.stroke();
  }
}
  • The pair loop is O(n²): doubling the points quadruples the distance checks, so the cost climbs far faster than the visual payoff. Keep the count low, and find your own ceiling by raising it on the weakest device you have until the frame rate drops. Past that point spatial partitioning is required and the effect stops being cheap

Technique: cursor-driven fields

  • A static grid of short strokes where each stroke rotates to point at the pointer. The positions never move; only orientation responds, which is why the whole surface feels magnetic for almost no cost
  • Per cell: get the angle to the pointer with Math.atan2(pointerY - cy, pointerX - cx), then save(), translate(cx, cy), rotate(angle), draw the stroke centered on the origin, restore(). Translating first is what makes each stroke pivot around its own center instead of the canvas corner
  • Scale each stroke's length with distance from the pointer, from full length nearby down to a dot far away. Clamp the mapping so distant strokes never go negative, and the dot form doubles as the neutral state before the pointer has ever moved, since a dot has no visible orientation to snap from
  • Line width, color or opacity can ride the same distance value for a richer falloff
  • This is the drawing half of the pattern. Composing the value each cell reads, feathering its edges, hit-testing the grid and running the field on its own clock all live in canvas-fields-and-grids.md

Common mistakes

  • Forgetting beginPath() before a new shape, so every stroke() repaints all earlier segments in the latest color
  • Treating closePath() as the end-of-drawing bookend, turning open strokes into closed outlines with an extra unwanted edge
  • Setting src before attaching the load listener in a one-shot paint, which blanks the canvas for repeat visitors with a cached image
  • Rebuilding a static gradient or a fixed Path2D inside the frame loop instead of hoisting it into setup
  • Reaching for Path2D as a performance fix on simple shapes without measuring, where it can cost more than it saves
  • Omitting the xmlns attribute on an SVG used as an image source, leaving the image broken
  • Passing degrees to arc or ellipse, which take radians
  • Reaching for arcTo to round a corner and getting an arc in an unexpected place at larger radii
  • Leaving globalCompositeOperation set to destination-out after an erase, so the next draw call deletes the scene
  • Drawing grid shapes from index 0 to the full width, which crops every shape on the edges by half its size
  • Feeding raw event.clientX into a canvas effect, so everything tracks a point offset by the canvas's distance from the viewport corner
  • Putting words that matter into fillText, where no screen reader can reach them and nothing wraps

When SVG is the better tool

For crisp UI chrome, icons, small charts, or anything that needs hover states, focus, or an accessible name, SVG wins: every shape stays a real element that scales sharply, styles with CSS and can speak to assistive technology, given a role="img" and an accessible name via <title> or aria-labelledby (decorative graphics get aria-hidden instead). Canvas earns its keep on computationally expensive work and on the paint tricks the DOM cannot do. The decision framework, and the accessibility requirements that come with either choice, is in canvas-animation.

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-ui-principle({ topic: "canvas-drawing" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems