Under the Hood
Canvas

Compositing and blending: how new pixels combine with old

When you draw on a canvas the new pixels don't simply replace what's underneath — they combine with it according to a rule called globalCompositeOperation, and switching that rule turns the same draw call into a mask, an eraser, or a blend, all governed by one source-versus-destination model.

Compositing and blending: how new pixels combine with old

Every lesson so far has treated drawing as painting on top: call fillRect, get a red rectangle, done. That's true by default, but it's a default — one setting among several. What actually happens whenever you draw is a combination: the pixels you're about to paint get merged with the pixels already sitting in the buffer according to a rule, and source-over (draw on top, like ink on paper) is simply the rule the canvas starts you with. Change the rule and the same fillRect can punch a hole instead of adding paint, or darken instead of covering, or only show up where something already exists. This lesson is about that rule, the small vocabulary of operators that implement it, and the two things people actually build with them: masking and erasing.

Source and destination

Every compositing operation has exactly two inputs, and the terminology is worth fixing before anything else, because the names are easy to swap by accident.

The source is the pixels you are about to draw — the shape, image, or text your current call is producing, together with its alpha (how transparent each of its pixels is). The destination is what's already sitting in the canvas buffer before this call runs — everything painted by every earlier command. Compositing is the operation that takes those two pixel grids, wherever they overlap, and decides what ends up in the buffer afterward.

globalCompositeOperation is the context property that names the rule. It's sticky state, like fillStyle — set it once and it governs every draw call until you change it back:

ctx.fillStyle = 'blue';
ctx.fillRect(20, 20, 100, 100);        // painted with source-over (the default)

ctx.globalCompositeOperation = 'destination-out';
ctx.fillStyle = 'red';
ctx.fillRect(60, 60, 100, 100);        // this call ERASES instead of painting

The Porter-Duff operators

The compositing model the canvas uses comes from a 1984 paper by Porter and Duff that named a small, complete set of ways two overlapping images can combine. Each operator answers one question: for a pixel where source and/or destination exist, what survives?

OperatorWhat ends up visible
source-over (default)Source on top of destination, wherever source has alpha — ordinary painting
destination-overDestination on top; source only fills in where destination was transparent
source-inSource, but only where it overlaps destination — everything else erased
source-outSource, but only where it does not overlap destination
destination-inDestination, but only where source overlaps it — everything else erased
destination-outDestination, but with the source's shape cut out of it
xorBoth, except where they overlap (the overlap is erased)
copySource only; destination discarded entirely

Two of these are worth internalizing on sight, because they're the ones you'll reach for constantly: source-in is masking — draw a photo, then draw a star shape on top with source-in, and only the star-shaped piece of the photo survives. destination-out is erasing — draw a shape with it active, and instead of adding pixels you remove whatever was already there in that shape's silhouette, which is exactly how a canvas "eraser tool" works.

Try a few of these directly: pick an operator, watch two overlapping shapes combine, and see which pixels the rule keeps.

globalCompositeOperation = 'source-over'

default — the source is painted on top of the destination, as usual.

destination — drawn first (sage) source — drawn second (terracotta)

New pixels don't simply replace old ones — globalCompositeOperation decides how the source (what you are about to draw) combines with the destination (what's already on the canvas).source-in and source-out use the destination as a mask; destination-outerases; multiply and screen are blend modes that darken or lighten the overlap instead of replacing pixels outright. The setting is reset to source-over after each draw so it doesn't leak into unrelated drawing later.

A masking example, with source-in:

// destination: a photo already drawn into the canvas
ctx.drawImage(photo, 0, 0);

// source: a star-shaped path
ctx.globalCompositeOperation = 'source-in';
ctx.beginPath();
drawStarPath(ctx, 150, 150, 80);
ctx.fill();
// result: only the star-shaped region of the photo remains; everywhere
// else is transparent, because source-in keeps the source ONLY where
// it overlapped the destination.

And erasing, with destination-out:

// destination: a drawing the user has been building up
ctx.globalCompositeOperation = 'destination-out';
ctx.beginPath();
ctx.arc(mouseX, mouseY, brushRadius, 0, Math.PI * 2);
ctx.fill();
// nothing new is "painted" — this call instead subtracts a disc from
// whatever pixels were already sitting under it. Fill color is irrelevant
// here: destination-out only uses the source's SHAPE and alpha.

Blend modes: same property, a different kind of math

globalCompositeOperation also accepts a second family of values that aren't Porter-Duff operators at all, but color blend modes borrowed from image-editing software: multiply, screen, overlay, darken, lighten, color-dodge, difference, and more. Where the operators above decide which pixels survive (a geometric, alpha-driven decision), blend modes assume both source and destination survive and instead decide what color comes out of combining their channels at every overlapping pixel.

The two anchor modes are worth knowing by name because the rest of the family sits between them: multiply multiplies each color channel of source and destination together (values 0–1), and multiplying two numbers less than 1 only ever gets smaller — so multiply always darkens, and multiplying by white (1) leaves a color unchanged while multiplying by black (0) erases it to black. screen is multiply's inversion — it always lightens, using the same math on the inverted channels — so multiplying by black leaves a color unchanged and multiplying by white washes it to white. Everything else (overlay, hard-light, soft-light, darken, lighten, difference, exclusion, hue, saturation, color, luminosity) is a variation on combining channels rather than choosing which pixels win.

ctx.globalCompositeOperation = 'multiply';
ctx.fillStyle = 'rgba(255, 0, 0, 1)';
ctx.fillRect(0, 0, 200, 200); // wherever this overlaps existing content,
                              // the result darkens toward that content
                              // rather than covering it

globalAlpha: a multiplier before compositing happens

globalAlpha is a separate sticky property, a number from 0 to 1, that scales the alpha of everything you draw before the compositing operator ever runs. It doesn't change which operator is active — it changes how transparent the source is going in. ctx.globalAlpha = 0.5 makes every subsequent draw call half as opaque as it would otherwise be, so a fully-opaque red fill becomes effectively 50% transparent red the moment it reaches the compositing step. It's the simplest way to fade a whole batch of draws without touching each shape's own color.

Clipping: constraining where anything can land

clip() is a different kind of restriction — not about how new and old pixels combine, but about where on the canvas a draw call is allowed to affect anything at all. It takes the current path (the same path machinery from paths: the two-phase model) and intersects it with the existing clip region. After that, every draw call — regardless of globalCompositeOperation — is masked to only take effect inside that region; pixels outside it are left completely untouched, as if a stencil were laid over the canvas.

ctx.save();               // clip region is part of saved state
ctx.beginPath();
ctx.arc(150, 150, 80, 0, Math.PI * 2);
ctx.clip();               // intersect current path with the clip region

ctx.fillStyle = 'blue';
ctx.fillRect(0, 0, 300, 300); // only the circular region actually gets painted
ctx.restore();            // clip region reverts to whatever it was before

Because the clip region is stored on the context alongside fillStyle and the transform, it's part of the state machine's save/restore stack covered earlier in this module — which is exactly why the pattern above is always save(), clip(), draw, restore(). Skip the save/restore pair and the clip silently keeps constraining every draw call from then on, because — like every other piece of context state — it never resets itself.

Where this goes next

Compositing decides how new pixels combine with what's underneath, but everything drawn so far has been paths — rectangles, arcs, lines. Two other kinds of content land in the buffer through completely different mechanics: text, which the browser rasterizes from fonts on your behalf, and images, which get blitted and scaled wholesale rather than traced point by point. Text, images, and direct pixel access takes those apart, along with the one way to reach past all of this and read the raw bytes yourself.

Go deeper

Check yourself

Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.

  1. Define source and destination precisely, and say which one a freshly-drawn shape is at the moment its draw call runs.
  2. What does source-in keep, and why does that make it useful for masking a photo into a shape?
  3. What does destination-out do to existing pixels, and why is fillStyle's color irrelevant when using it?
  4. How does a blend mode like multiply differ from a Porter-Duff operator like source-over in what kind of decision it makes about the resulting pixel?
  5. Why does multiply always darken and screen always lighten? Answer in terms of the channel math.
  6. What does globalAlpha scale, and at what point in the compositing process does it apply?
  7. Why is clip() typically wrapped in save() and restore(), and what would happen if you called clip() without ever restoring?