Crisp pixel art look with image-rendering
This article discusses a useful technique for giving your canvas/WebGL games a crisp pixel art look, even on high definition monitors.
The concept
Retro pixel art aesthetics are getting popular, especially in indie games or game jam entries. But since today's screens render content at high resolutions, there is a problem with making sure the pixel art does not look blurry. Here's an original image that an actual arcade game may have used:
We can manually scale it up in an image editor, expanding each pixel into a 4x4 block of pixels. The image editor can leverage algorithms like nearest-neighbor interpolation to achieve crisp edges.
Two downsides to this method are larger file sizes and compression artifacts, because the image actually contains more pixels.
The idea of producing crisp pixel art is simple: we want to have a single pixel in the original image map to a block of pixels on the screen, without any smoothing or blending between them. The example above achieves this by manually doing that mapping in an image editor. But we can also achieve this effect in the browser using CSS.
Up-scaling <img> with CSS
An image has an intrinsic size, which is its actual pixel dimensions. It also has a rendered size, which is set by HTML or CSS. If the rendered size is larger than the intrinsic size, the browser will automatically scale up the image to fit the rendered size.
<img
src="technique_original.png"
alt="small pixelated man, upscaled with width and height attributes, appearing blurry" />
img {
width: 48px;
height: 136px;
}

But as you can see in the image above, the browser's default scaling algorithm makes the image look blurry. This is because it uses a smoothing algorithm that averages the colors of pixels to create a smooth transition between them.
To fix this, we can use the CSS property image-rendering
to tell the browser to use a different scaling algorithm that preserves the hard edges of pixel art.
<img
src="technique_original.png"
alt="small pixelated man, upscaled with CSS, appearing crisp" />
img {
width: 48px;
height: 136px;
image-rendering: pixelated;
}

There are also the crisp-edges
and -webkit-optimize-contrast
values that work on some browsers. Check out the image-rendering
article for more information on the differences between these values, and which values to use depending on the browser.
image-rendering: pixelated
is not without its problems as a crisp-edge-preservation technique. When CSS pixels don't align with device pixels (if the devicePixelRatio
is not an integer), certain pixels may be drawn larger than others, resulting in a non-uniform appearance. For example, in Chrome and Firefox, when you zoom in or out, the devicePixelRatio
changes. This can cause the pixel art to appear distorted or uneven. The screenshot below is taken at 110% page zoom in Chrome. If you look closely, you can see that the left edge of the character's face and leg appears uneven.
This is not an easy problem to solve, however, because it is impossible to fill device pixels precisely when the CSS pixels cannot accurately map to them.
Crisp pixel art in canvas
Many games render inside a <canvas>
element, which can use the same image-rendering
technique because canvases are also raster images. The steps to achieve this are:
- Create a
<canvas>
element and set itswidth
andheight
attributes to the original, smaller resolution. - Set its CSS
width
andheight
properties to be any value you want, but stretched equally to preserve the aspect ratio. If the canvas was created with a 128 pixel width, for example, we would set the CSSwidth
to512px
if we wanted a 4x scale. - Set the
<canvas>
element'simage-rendering
CSS property topixelated
.
Let's have a look at an example. The original image we want to upscale looks like this:
Here's some HTML to create a simple canvas:
<canvas id="game" width="128" height="128">A cat</canvas>
CSS to size the canvas and render a crisp image:
canvas {
width: 512px;
height: 512px;
image-rendering: pixelated;
}
And some JavaScript to set up the canvas and load the image:
// Get canvas context
const ctx = document.getElementById("game").getContext("2d");
// Load image
const image = new Image();
image.onload = () => {
// Draw the image into the canvas
ctx.drawImage(image, 0, 0);
};
image.src = "cat.png";
This code used together produces the following result:
Note:
Canvas content is not accessible to screen readers. Include descriptive text as the value of the aria-label
attribute directly on the canvas element itself or include fallback content placed within the opening and closing canvas tag. Canvas content is not part of the DOM, but nested fallback content is.
Arbitrarily scaling images in canvas
For the character example with a plain <img>
, you can set the scale factor to any value you want, and image-rendering: pixelated
will do its best to preserve crisp edges. For example, you can scale the image by 5.7x:
img {
/* 5.7x scale factor */
width: 68.4px;
height: 193.8px;
image-rendering: pixelated;
}

Previously, we said that image-rendering: pixelated
works at the stage of mapping image pixels to CSS pixels. But if we are drawing the image into a canvas, we have two layers of mapping: from image pixels to canvas pixels, and then from canvas pixels to CSS pixels. The second step works the same way as image scaling with <img>
, so you can also use arbitrary scale factors when scaling the canvas with CSS:
canvas {
/* 3.7x scale factor */
width: 473.6px;
height: 473.6px;
image-rendering: pixelated;
}
But we need to be careful with how the image pixels are aligned with the canvas pixels. By default the image pixels are drawn 1:1 to canvas pixels; however, if you use the extra arguments of drawImage()
to draw the image at a different size in the canvas, you may end up with a non-integer scale factor. For example, if you draw a 128x128 pixel image into a 100x100 pixel area on the canvas, each image pixel will be drawn as 0.78x0.78 canvas pixels, which can lead to blurriness.
// Get canvas context
const ctx = document.getElementById("game").getContext("2d");
// Load image
const image = new Image();
image.onload = () => {
// Extract the image pixels from (0,0) to (128,128) (full size)
// and draw them into the canvas at (0,0) to (100,100)
ctx.drawImage(image, 0, 0, 128, 128, 0, 0, 100, 100);
};
image.src = "cat.png";
The same happens if you use scale()
to scale the canvas grid. In this case, a unit of 1 when calling canvas methods would be interpreted as a non-integer number of canvas pixels, leading to blurriness.
// Get canvas context
const ctx = document.getElementById("game").getContext("2d");
// Scaling the context by 0.8, so each image pixel is drawn as 0.8x0.8 canvas pixels
ctx.scale(0.8, 0.8);
// Load image
const image = new Image();
image.onload = () => {
ctx.drawImage(image, 0, 0);
};
image.src = "cat.png";
To fix this, you have to ensure that the image pixels are always drawn at integer multiples of canvas pixels. That is, when you call drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight)
, dWidth
needs to be equal to sWidth / xScale * n
, where xScale
is the x scale factor for the context (1.0 if you haven't called scale()
), and n
is an integer (1, 2, 3, ...). The same applies to dHeight
. So if you want to draw a 128x128 pixel image on a canvas that has been scaled by 0.8, you can only draw it at sizes like 160 (128 / 0.8 * 1), 320 (128 / 0.8 * 2), etc.
// Get canvas context
const ctx = document.getElementById("game").getContext("2d");
// Scaling the context by 0.8, so each image pixel is drawn as 0.8x0.8 canvas pixels
ctx.scale(0.8, 0.8);
// Load image
const image = new Image();
image.onload = () => {
ctx.drawImage(image, 0, 0, 128, 128, 0, 0, 128 / 0.8, 128 / 0.8);
};
image.src = "cat.png";
See the canvas drawing shapes guide for more information about how canvas pixels work.