Skip to main content
Logo for STRICH Barcode Scanning SDK Between the Margins

Back to all posts

Creating an Overlay with a Transparent Cutout

Published on by alex.suzuki · 4 min read

Motivation

Recently one of my customers approached me with a feature request for STRICH that I think makes a lot of sense.

Instead of just drawing a rectangle (called “Viewfinder”) over the region where barcodes are detected, the region outside of the rectangle should also be dimmed, steering user focus to where the action is.

BeforeAfter
BeforeAfter

My first reaction was: this is easy! The rectangle is drawn on a canvas that is layered on top of the camera feed. Surely we can colorize the outside area by clipping the inside area.

Clipping

My first approach was to define the rectangle as the clipping path using the clip method, then fill the entire canvas with a semi-transparent color (black with 50% opacity in this example).

  const ctx = canvas.getContext('2d');
  ctx.beginPath();
  ctx.rect((canvas.width - rectW)/2, (canvas.height - rectH)/2, rectW, rectH);
  ctx.clip();
  ctx.fillStyle = 'rgba(0,0,0,0.5)';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

Which lead to the following result:

Clipped

Ok, that didn’t work out. clip() actually works the other way round! Whatever we do to the canvas will be restricted to the clip path. What I wanted instead was “do the thing everywhere except here”, but a quick check of MDN didn’t yield anything useful – there’s no such thing as an “inverse clip”.

So what can we do?

Raw Dogging It

We could fill each of the four outside rectangles individually.

  const ctx = canvas.getContext('2d');
  ctx.fillStyle = 'rgba(0,0,0,0.5)'; // top
  ctx.fillRect(0, 0, canvas.width, (canvas.height - rectH)/2);
  ctx.fillStyle = 'rgba(255,0,0,0.5)'; // bottom
  ctx.fillRect(0, (canvas.height + rectH)/2, canvas.width, (canvas.height - rectH)/2);
  ctx.fillStyle = 'rgba(0,255,0,0.5)'; // left
  ctx.fillRect(0, rectH, (canvas.width - rectW)/2, rectH);
  ctx.fillStyle = 'rgba(0,0,255,0.5)'; // right
  ctx.fillRect((canvas.width + rectW)/2, rectH, (canvas.width - rectW)/2, rectH);

Here I’ve used separate colors for every region to make it clear what’s happening.

Raw Dogging

Not exactly elegant, but better than nothing!

Fill and Clear

The solution turned out to be surprisingly simple. Fill the entire canvas, and then use clearRect to erase the contents of the rectangle, thus making it transparent.

  const ctx = canvas.getContext('2d');
  ctx.fillStyle = 'rgba(0,0,0,0.5)';
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  ctx.clearRect((canvas.width - rectW)/2, (canvas.height - rectH)/2, rectW, rectH);

Fill and Clear

🥊 We’ve successfully punched a hole in this canvas!

Plot Twist: Rounded Rect Rears its Ugly Head

You may have noticed that I cheated a little. The mockup used rounded corners instead of sharp ones for the rectangle. Turns out that Canvas has a roundRect method that complements the rect method, hooray!

So let’s call clearRoundRect() and we should be good to go, right?

clearRoundRect does not exist

Oh no – there’s no way of clearing a rounded rectangle! Seems like rounded rectangles are not a first-class citizen yet in the Canvas API.

But worry not… remember that old friend clip that we shunned earlier and felt enlightened because we thought we didn’t need it? We need its help after all. We can use clip to limit the effect of clearRect to a rounded rectangle clipping path!

  const ctx = canvas.getContext("2d");
  ctx.fillStyle = 'rgba(0,0,0,0.5)';
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  ctx.roundRect((canvas.width - rectW)/2, (canvas.height - rectH)/2, rectW, rectH, 20);
  ctx.clip(); // restrict clearRect to the roundRect clipping path
  ctx.clearRect((canvas.width - rectW)/2, (canvas.height - rectH)/2, rectW, rectH);

clipping path for clearRect

Finally – A cutout in the form of a rounded rectangle!

Drawing the Border

All that’s left is drawing the border around the rounded cutout. That’s just another call to roundRect, but we need to remove the clipping path first, otherwise it will clip the stroke.

As there’s no way to directly remove a clipping path, so we need to use the save and restore methods to revert the canvas to its state before the clipping path was activated.

The final code:

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

  // draw the transparent overlay with a rounded rectangle cutout
  ctx.save();
  ctx.fillStyle = 'rgba(0,0,0,0.5)';
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  ctx.roundRect((canvas.width - rectW)/2, (canvas.height - rectH)/2, rectW, rectH, 20);
  ctx.clip();
  ctx.clearRect((canvas.width - rectW)/2, (canvas.height - rectH)/2, rectW, rectH);
  ctx.restore(); // revert canvas (remove clipping path)

  // draw the frame
  ctx.strokeStyle = 'white';
  ctx.lineWidth = 2;
  ctx.roundRect((canvas.width - rectW)/2, (canvas.height - rectH)/2, rectW, rectH, 20);
  ctx.stroke();

final result

Success! We made a customer happy and learned something along the way.

Be careful with roundRect, it’s only been been widely available since early 2023, so still relatively new. In my case, I added graceful degradation to rect – sad panda eyes for lovers of both rounded rectangles and vintage browsers.