Responsive Canvas Rendering
In web development, 'responsive' typically refers to layout. Adjusting the layout of a page based on the dimensions of the renderable area. A more formal definition from Wikipedia:
Responsive web design (RWD) or responsive design is an approach to web design that aims to make web pages render well on a variety of devices and window or screen sizes from minimum to maximum display size to ensure usability and satisfaction.
Here, I will share a few key techniques I always apply when rendering pixels on
a <canvas>
element, while ensuring the rendering properly responds to
canvas size and Device Pixel Ratio (DPR).
This is not a comprehensive tutorial that aims to teach on canvas rendering from scratch. Code excerpts are partial, only highlighting key changes from the previous example. Knowledge of JavaScript and some algebra is assumed.
Defaults
Let's start with a default canvas:
<canvas></canvas>
By default, the canvas is a transparent rectangle measuring 300x150 pixels (spec). I have added a border around it to clearly define its size and position on the page.
Reference square
Now, let's fill it black and then render a 100x100 white square in its middle:
<canvas id="canvas"></canvas>
const canvas = document.querySelector("#canvas");
const ctx = canvas.getContext("2d");
ctx.fillRect(0, 0, canvas.width, canvas.height);
const SIZE = 100;
ctx.fillStyle = "white";
ctx.fillRect(
canvas.width / 2 - SIZE / 2,
canvas.height / 2 - SIZE / 2,
SIZE,
SIZE
);
Intrinsic dimensions
Now let's stretch the canvas to 100% of the width of the container:
canvas {
width: 100%;
height: 150px;
}
Why is our square no longer square? Because we resized the canvas with
CSS and didn't attach width
and height
attributes to the <canvas>
tag, the
intrinsic dimensions of the canvas are still the defaults, 300x150.
We can fix this by setting the width
and height
properties of the canvas
element to its pixel dimensions:
const {width, height} = canvas.getBoundingClientRect();
canvas.width = width;
canvas.height = height;
Reference circle
Next, let's switch to rendering a circle to achieve anti-aliased edges. And let's also print "1.0", and render a reference 20x20 square, both of which will become significant soon.
const RADIUS = 50;
ctx.beginPath();
ctx.arc(canvas.width / 2, canvas.height / 2, RADIUS, 0, Math.PI * 2);
ctx.fill();
ctx.font = '2em monospace';
ctx.fillText('1.0', 10, 35);
// 20x20 pixel reference square
ctx.fillRect(10, 50, 20, 20);
Crisp rendering
The CSS dimensions we set on the canvas are the logical pixel dimensions. Your
browser's DPR (Device Pixel Ratio) is a multiplier that determines the physical
pixel density of your screen. For example, if your DPR is 2.0
, then for every
logical pixel, there are 4 physical pixels (2x2). To render this circle in the
crispiest way possible we apply the DPR as a multiplier to the canvas
dimensions. We'll also print your actual DPR instead of the "1.0" we hard-coded
earlier:
const dpr = window.devicePixelRatio;
const {width, height} = canvas.getBoundingClientRect();
canvas.width = width * dpr;
canvas.height = height * dpr;
But then, we also have to start factoring in DPR in all size units when using the drawing primitives:
// Adjust for DPR
const radius = RADIUS * dpr;
ctx.beginPath();
ctx.arc(
canvas.width / 2,
canvas.height / 2,
radius,
0,
Math.PI * 2
);
ctx.fill();
// Display DPR
ctx.font = '2em monospace';
ctx.fillText(dpr.toFixed(2), 10, 35);
DPR comparison
If your DPR is greater than 1.0
, you should see the 20x20 pixel reference
square rendered as something smaller than 20x20 while our circle remains the
same size. And the anti-aliased edges of the circle should now look as crisp as
possible on your screen (without getting into subpixel rendering).
In case your DPR is 1.0
, and you don't see a difference above, here's what the
comparison would have approximately looked like against a DPR of 2.0
:
Scene layout
In the examples so far we've used hard-coded dimensions for the elements because we knew the canvas height was always 150px. A square 100px across or a circle 50px in radius would nicely fit.
Defining the scene in proportions, such as a fraction of the canvas height, simplifies dynamic layouts. So, let's switch to rendering our circle with a radius of 1/3 of the canvas height:
const radius = canvas.height / 3;
ctx.fillStyle = 'white';
ctx.beginPath();
ctx.arc(
canvas.width / 2,
canvas.height / 2,
radius,
0,
Math.PI * 2
);
ctx.fill();
Resizing
Typically, a canvas will be set up to be dynamically resized to fit its container. After rendering the circle once, let's start changing the width of the canvas container:
As the container width changes, the circle stretches into an ellipse because the canvas pixels are resampled to fit the new dimensions.
Brute-force rendering
One way to address this is to set up a rendering loop to keep adjusting the intrinsic dimensions of the canvas and rendering the scene every frame. This way, the entire scene will always be rendered at the correct proportions. We'll use a requestAnimationFrame (RAF) loop for this:
function draw() {
// Request the next frame before we draw this one
requestAnimationFrame(draw);
const dpr = window.devicePixelRatio;
const {width, height} = canvas.getBoundingClientRect();
canvas.width = width * dpr;
canvas.height = height * dpr;
ctx.fillStyle = 'black';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const radius = canvas.height / 6;
ctx.fillStyle = 'white';
ctx.beginPath();
ctx.arc(
canvas.width / 2,
canvas.height / 2,
radius,
0,
Math.PI * 2
);
ctx.fill();
}
// Kick off the loop
requestAnimationFrame(draw);
The circle remains a circle now.
Preserving the aspect ratio
Instead of rendering a static scene every frame, another option is to preserve the aspect ratio of the canvas so that the scene doesn't stretch or squash. We can render the canvas once and let CSS do its thing:
<div class="container">
<canvas></canvas>
</div>
/* How this container itself fits into the layout is up to you */
.container {
aspect-ratio: 300 / 150;
}
canvas {
/* Alternatively, use Flexbox */
width: 100%;
height: 100%;
}
The circle remains a circle.
Avoid resampling
However, the ugly truth is that you will lose all the crispy rendering you got at the start by updating the intrinsic dimensions of the canvas with DPR in mind. Even when initially rendering big and then scaling down, resampling can cause undesirable artifacts. And initially rendering small and then scaling up will create blurry results:
Debounced redraw
We can watch for the resizing of the canvas and redraw the scene on a debounce. This approach allows resampling during resizing but ensures accurate proportions once resizing stops.
Let's do this efficiently. To watch for resizing, we'll use the ResizeObserver browser API:
// One could always use Lodash's debounce
function tailDebounce(fn, delay) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
let contentRect = null;
const draw = () => {
// Always draw when the time is right
requestAnimationFrame(() => {
const dpr = window.devicePixelRatio;
const rect = contentRect || canvas.getBoundingClientRect();
const {width, height} = rect;
canvas.width = width * dpr;
canvas.height = height * dpr;
ctx.fillStyle = 'black';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const radius = canvas.height / 3;
ctx.fillStyle = 'white';
ctx.beginPath();
ctx.arc(
canvas.width / 2,
canvas.height / 2,
radius,
0,
Math.PI * 2
);
ctx.fill();
});
};
const debouncedDraw = tailDebounce(draw, 100);
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.target === canvas && entry.contentRect) {
contentRect = entry.contentRect;
debouncedDraw();
}
}
});
draw();
resizeObserver.observe(canvas);
If you look closely, especially when scaling up, you can see the scene being resampled, creating a blurry edge around the circle. When the resizing stops for at least 100ms, the scene is redrawn and the crispiness is restored.
Stroke width and font size
Here's a hollow circle with a 3px border, with the text "circle" in the middle:
ctx.lineWidth = 3;
ctx.strokeStyle = 'white';
ctx.beginPath();
ctx.arc(
canvas.width / 2,
canvas.height / 2,
radius,
0,
Math.PI * 2
);
ctx.stroke();
const fontSize = 1 * dpr;
const fontFace = 'Varela Round';
ctx.fillStyle = 'white';
ctx.font = `${fontSize}em ${fontFace}`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('circle', canvas.width / 2, canvas.height / 2);
The stroke width is not affected by the intrinsic resolution of the canvas. When you set the stroke width to 3, it will always be 3 logical pixels wide. Font size, however, needs to account for DPR adjustments.
Here's the same scene without the DPR adjustment:
If your DPR is greater than 1, the border should still appear more or less the same thickness as the previous one, but blurrier.
Solid shapes, images, and text should consider DPR for clarity. Strokes not so.