Pixel-perfect rendering with devicePixelContentBox

How many pixels are there really in a canvas?

Since Chrome 84, ResizeObserver supports a new box measurement called devicePixelContentBox, that measures the element's dimension in physical pixels. This enables rendering pixel-perfect graphics, especially in the context of high-density screens.

Browser Support

  • Chrome: 84.
  • Edge: 84.
  • Firefox: 93.
  • Safari: not supported.

Source

Background: CSS pixels, canvas pixels, and physical pixels

While we often work with abstract units of length like em, % or vh, it all boils down to pixels. Whenever we specify the size or position of an element in CSS, the browser's layout engine will eventually convert that value to pixels (px). These are "CSS Pixels", which have a lot of history and only have a loose relationship with the pixels you have on your screen.

For a long time, it was fairly reasonable to estimate anyone's screen pixel density with 96DPI ("dots per inch"), meaning any given monitor would have roughly 38 pixels per cm. Over time, monitors grew and/or shrunk or started to have more pixels on the same surface area. Combine that with the fact that lots of content on the web define their dimensions, including font sizes, in px, and we end up with illegible text on these high-density ("HiDPI") screens. As a counter-measure, browsers hide the monitor's actual pixel density and instead pretend that the user has a 96 DPI display. The px unit in CSS represents the size of one pixel on this virtual 96 DPI display, hence the name "CSS Pixel". This unit is only used for measurement and positioning. Before any actual rendering happens, a conversion to physical pixels happens.

How do we go from this virtual display to the user's real display? Enter devicePixelRatio. This global value tells you how many physical pixels you need to form a single CSS pixel. If devicePixelRatio (dPR) is 1, you are working on a monitor with roughly 96DPI. If you have a retina screen, your dPR is probably 2. On phones it is not uncommon to encounter higher (and weirder) dPR values like 2, 3 or even 2.65. It is essential to note that this value is exact, but doesn't let you derive the monitor's actual DPI value. A dPR of 2 means that 1 CSS pixel will map to exactly 2 physical pixels.

Example
My monitor has a dPR of 1 according to Chrome…

It has 3440 pixels in width and the display area is 79cm wide. That leads to a resolution of 110 DPI. Close to 96, but not quite. That is also the reason why a <div style="width: 1cm; height: 1cm"> will not exactly measure 1cm in size on most displays.

Finally, dPR can also be affected by your browser's zoom feature. If you zoom in, the browser increases the reported dPR, causing everything to render bigger. If you check devicePixelRatio in a DevTools Console while zooming, you can see fractional values appear.

DevTools showing a variety of fractional devicePixelRatio due to zooming.

Let's add the <canvas> element to the mix. You can specify how many pixels you want the canvas to have using the width and height attributes. So <canvas width=40 height=30> would be a canvas with 40 by 30 pixels. However, this does not mean that it will be displayed at 40 by 30 pixels. By default, the canvas will use the width and height attribute to define its intrinsic size, but you can arbitrarily resize the canvas using all the CSS properties you know and love. With everything we have learned so far, it might occur to you that this will not be ideal in every scenario. One pixel on the canvas might end up covering multiple physical pixels, or just a fraction of a physical pixel. This can lead to unpleasing visual artifacts.

To summarize: Canvas elements have a given size to define the area that you can draw on. The number of canvas pixels is completely independent from the canvas' display size, specified in CSS pixels. The number of CSS pixels is not the same as the number of physical pixels.

Pixel perfection

In some scenarios, it is desirable to have an exact mapping from canvas pixels to physical pixels. If this mapping is achieved, it's called "pixel-perfect". Pixel-perfect rendering is crucial for legible rendering of text, especially when using subpixel rendering or when displaying graphics with tightly aligned lines of alternating brightness.

To achieve something as close to a pixel-perfect canvas as possible on the web, this has been more or less the go-to approach:

<style>
  /* … styles that affect the canvas' size … */
</style>
<canvas id="myCanvas"></canvas>
<script>
  const cvs = document.querySelector('#myCanvas');
  // Get the canvas' size in CSS pixels
  const rectangle = cvs.getBoundingClientRect();
  // Convert it to real pixels. Ish.
  cvs.width = rectangle.width * devicePixelRatio;
  cvs.height = rectangle.height * devicePixelRatio;
  // Start drawing…
</script>

The astute reader might be wondering what happens when dPR is not an integer value. That is a good question and exactly where the crux of this entire problem lies. In addition, if you specify an element's position or size using percentages, vh, or other indirect values, it is possible that they will resolve to fractional CSS pixel values. An element with margin-left: 33% can end up with a rectangle like this:

DevTools showing fractional pixel values as a result of a getBoundingClientRect() call.

CSS pixels are purely virtual, so having fractions of a pixel is okay in theory, but how does the browser figure out the mapping to physical pixels? Because fractional physical pixels are not a thing.

Pixel snapping

The part of the unit conversion process that takes care of aligning elements with physical pixels is called "pixel snapping", and it does what it says on the tin: It snaps fractional pixel values to integer, physical pixel values. How exactly this happens is different from browser to browser. If we have an element with a width of 791.984px on a display where dPR is 1, one browser might render the element at 792px physical pixels, while another browser might render it at 791px. That's just a single pixel off, but a single pixel can be detrimental to renderings that need to be pixel-perfect. This can lead to blurriness or even more visible artifacts like the Moiré effect.

The top image is a raster of differently colored pixels. The bottom image is the same as above, but the width and height have been reduced by one pixel using bilinear scaling. The emerging pattern is called the Moiré effect.
(You might have to open this image in a new tab to see it without any scaling applied to it.)

devicePixelContentBox

devicePixelContentBox gives you an element's content box in device pixel (i.e. physical pixel) units. It's part of ResizeObserver. While ResizeObserver is now supported in all major browsers since Safari 13.1, the devicePixelContentBox property is only in Chrome 84+ for now.

As mentioned in ResizeObserver: it's like document.onresize for elements, the callback function of a ResizeObserver will be called before paint and after layout. That means that the entries parameter to the callback will contain the sizes of all observed elements just before they are being painted. In the context of our canvas problem outlined above, we can use this opportunity to adjust the number of pixels on our canvas, ensuring that we end up with an exact one-to-one mapping between canvas pixels and physical pixels.

const observer = new ResizeObserver((entries) => {
  const entry = entries.find((entry) => entry.target === canvas);
  canvas.width = entry.devicePixelContentBoxSize[0].inlineSize;
  canvas.height = entry.devicePixelContentBoxSize[0].blockSize;

  /* … render to canvas … */
});
observer.observe(canvas, {box: ['device-pixel-content-box']});

The box property in the options object for observer.observe() lets you define which sizes you wish to observe. So while each ResizeObserverEntry will always provide borderBoxSize, contentBoxSize and devicePixelContentBoxSize (provided the browser supports it), the callback will only be invoked if any of the observed box metrics change.

With this new property, we can even animate our canvas' size and position (effectively guaranteeing fractional pixel values), and not see any Moiré effects on the rendering. If you would like to see the Moiré effect on the approach using getBoundingClientRect(), and how the new ResizeObserver property allows you to avoid it, take a look at the demo in Chrome 84 or later!

Feature detection

To check if a user's browser has support for devicePixelContentBox, we can observe any element, and check if the property is present on the ResizeObserverEntry:

function hasDevicePixelContentBox() {
  return new Promise((resolve) => {
    const ro = new ResizeObserver((entries) => {
      resolve(entries.every((entry) => 'devicePixelContentBoxSize' in entry));
      ro.disconnect();
    });
    ro.observe(document.body, {box: ['device-pixel-content-box']});
  }).catch(() => false);
}

if (!(await hasDevicePixelContentBox())) {
  // The browser does NOT support devicePixelContentBox
}

Conclusion

Pixels are a surprisingly complex topic on the web and up until now there was no way for you to know the exact number of physical pixels an element occupies on the user's screen. The new devicePixelContentBox property on a ResizeObserverEntry gives you that piece of information and allows you to do pixel-perfect renderings with <canvas>. devicePixelContentBox is supported in Chrome 84+.