Why Your HTML5 Canvas Looks Blurry, and How to Fix It
A canvas has two sizes, and they are easy to confuse:
- The display size — how large the element is on the page, in CSS pixels.
- The bitmap size — how many real pixels the browser allocates to draw into.
When the bitmap is smaller than the display size, the browser stretches the result and the drawing looks soft. On a retina screen the display size is already two or three device pixels wide for every CSS pixel, so a canvas that allocates one bitmap pixel per CSS pixel is stretched by default.
Konva already handles this
This is the part most advice gets wrong, so it is worth stating plainly.
Konva.pixelRatio defaults to window.devicePixelRatio, and every canvas Konva
creates allocates its bitmap at that ratio. You give Konva sizes in CSS
pixels and it sizes the bitmap for you.
// This is correct on a retina screen. Nothing else is needed.
const stage = new Konva.Stage({
container: 'container',
width: 600, // CSS pixels
height: 400, // CSS pixels
});
On a 2× screen that stage draws into a 1200 × 800 bitmap and displays at 600 × 400. Text and strokes are crisp.
The fix that makes it worse
Search for "blurry canvas" and you will find this pattern, which is correct for
a raw <canvas> and wrong for Konva:
// DO NOT do this in Konva
const dpr = window.devicePixelRatio;
stage.width(container.clientWidth * dpr);
stage.height(container.clientHeight * dpr);
stage.scale({ x: dpr, y: dpr });
It multiplies by the device pixel ratio twice: once here, and once inside Konva. On a 2× screen the stage ends up twice as large as its container and renders into a bitmap four times the area it needs. The drawing is sharp, so the mistake is easy to miss — what you notice is that the scene is too big and the memory use is high.
The advice is not wrong in general. It is what you would write against a bare canvas context, where nothing sets the bitmap size for you. Konva is not a bare context.
When it really is blurry
If a Konva scene still looks soft, the cause is usually one of these.
The container is scaled with CSS
A transform: scale() on the container, or a width in percent that does not
match the stage size, stretches the finished bitmap. Konva cannot see that.
Size the stage to the container instead — see
Responsive Canvas.
pixelRatio was turned off
Konva.pixelRatio = 1 is a real performance tip, and it costs sharpness on
retina screens. It is worth it for heavy scenes and not worth it for text.
If someone set it globally, that is your answer.
Konva.pixelRatio = 1; // faster, and soft on retina
A cached node was cached at the wrong ratio
node.cache() renders the node into its own bitmap. That bitmap is fixed at the
moment you cache it, so a node cached before a zoom is upscaled afterwards.
Cache at the ratio you will display at, or re-cache after the scale changes.
node.cache({ pixelRatio: 2 });
An exported image is blurry
Export is a separate setting. toDataURL() defaults to pixelRatio: 1
regardless of the screen, so an export looks softer than the canvas it came
from. Ask for more:
stage.toDataURL({ pixelRatio: 2 });
There is more on this in High quality export.
Thin lines look soft, not blurry
A one-pixel stroke drawn at a whole coordinate straddles two pixels, because the line is centred on the path. Half of it lands in each, and the browser blends both. This is a different problem from device pixel ratio and it happens at any zoom level.
Offset by half a pixel so the stroke fills one row:
const line = new Konva.Line({
points: [10, 20.5, 200, 20.5], // .5 puts the 1px stroke inside one pixel row
stroke: 'black',
strokeWidth: 1,
});
The demo below draws the same horizontal line twice. The top one sits on a whole coordinate and looks grey; the bottom one is offset by half a pixel and looks black.
Konva can do this for you on shapes with a stroke. strokeScaleEnabled and
perfectDrawEnabled control related behaviour, and for crisp axis-aligned
strokes the half-pixel offset is the reliable answer.
Reading the actual numbers
When you are not sure which size is wrong, print both:
const canvas = stage.container().querySelector('canvas');
console.log('CSS size ', canvas.clientWidth, canvas.clientHeight);
console.log('bitmap size', canvas.width, canvas.height);
console.log('ratio ', canvas.width / canvas.clientWidth);
console.log('devicePixelRatio', window.devicePixelRatio);
If the ratio matches devicePixelRatio, the canvas is correct and the softness
is coming from CSS, from a cache, or from an export. If the ratio is 1 on a
retina screen, pixelRatio was turned off somewhere.