Why Is My Canvas Blurry?
I've been digging into interactive canvas lately. The first problem I hit was: why does everything I draw look blurry? The usual answer is "multiply the canvas size by devicePixelRatio". That's correct, but in practice it only gets you halfway. This post walks through what I found. Two kinds of pixels DPR (Device Pixel Ratio) is the ratio between physical pixels and CSS pixels, exposed as window.devicePixelRatio. * Physical pixels are the smallest light-emitting dots on the screen. * CSS
Ching HuiI've been digging into interactive canvas lately. The first problem I hit was: why does everything I draw look blurry?
The usual answer is "multiply the canvas size by devicePixelRatio". That's correct, but in practice it only gets you halfway. This post walks through what I found.
Two kinds of pixels
DPR (Device Pixel Ratio) is the ratio between physical pixels and CSS pixels, exposed as window.devicePixelRatio.
- Physical pixels are the smallest light-emitting dots on the screen.
- CSS pixels are an abstract unit that keeps a page looking the same size across different hardware.
On a Retina display with DPR 2, one CSS pixel maps to 2 × 2 = 4 physical pixels. When you write width: 1px; height: 1px, the screen actually paints a 2×2 grid. That's why text and lines look so sharp on high-resolution displays.
A canvas has two sizes
What makes <canvas> unusual is that it has two independent sizes:
- Drawing buffer size: how many pixels the canvas actually holds in memory, set by
canvas.width/canvas.height. The default is 300 × 150. - Display size: how big the canvas appears on the page, set by CSS.
When they don't match, the browser stretches the buffer to the CSS size, the same way it would scale an image. On a DPR 2 display, if the buffer is only as large as the CSS size, every pixel gets blown up into a 2×2 block, and the result is blurry.
Basic Solution
const dpr = window.devicePixelRatio;
const rect = canvas.getBoundingClientRect();
canvas.width = Math.round(rect.width * dpr);
canvas.height = Math.round(rect.height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);Setting canvas.width or canvas.height (even if the value remains unchanged) clears the canvas and resets the context state, including transform, fillStyle, lineWidth, and so on. Therefore, after every size adjustment, you must re-apply the transform and redraw the entire canvas.
Note:
- Without a transform: If your canvas physical pixels become 2x (e.g., width changes from 300 to 600), calling
ctx.fillRect(0, 0, 100, 100)will only occupy a small corner of the screen (because the CSS size on screen is still 300). You have to manually multiply all drawing coordinates and sizes by the DPR. - With a transform: By scaling the coordinate system using
ctx.setTransform(dpr, 0, 0, dpr, 0, 0), you can draw directly using logical CSS pixels. For example, writingctx.fillRect(20, 20, 100, 100)will cause the browser to automatically scale it by the corresponding multiplier and render it cleanly on the physical pixels.
Three cases you'll hit in practice
Case 1: The user zooms, or drags the window to another monitor
In Chrome and Firefox, Ctrl/⌘ + zoom changes devicePixelRatio on the fly. So does dragging a window from a regular monitor to a Retina one. If you don't recompute, the canvas goes blurry or the proportions break.
The common approach is to listen with matchMedia:
function listenDpr(onChange: () => void) {
const mql = matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
mql.addEventListener('change', () => {
onChange();
listenDpr(onChange); // re-register
}, { once: true });
}Notice that the query hard-codes the current DPR value. Once DPR changes from 1 to 2, that query simply stops matching. It won't fire again when DPR later changes to 3. You have to re-register with the new value every time.
Case 2: Fractional DPR
Every example below uses the same canvas:
- CSS width: 266.72 px
- DPR: 1.25 (e.g. Windows at 125% scaling)
- Left edge: 10.64 CSS px (chosen for the demo)
The core problem
CSS size × DPR is usually not an integer, but canvas.width must be one. So the buffer size has to be rounded, and even then it may not match how many physical pixels the browser actually gives the element.
Step 1: Pick the buffer size
const dpr = devicePixelRatio;
const rect = canvas.getBoundingClientRect();
canvas.width = Math.round(rect.width * dpr);
canvas.height = Math.round(rect.height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); d266.72 × 1.25 = 333.4 canvas.width = Math.round(333.4) = 333
| Computed | Without Math.round | With Math.round |
|---|---|---|
| 333.4 | 333 | 333 |
| 333.6 | 333 | 334 |
| 333.9 | 333 | 334 |
Here the result is 333 either way. The two only differ when the fraction is .5 or more.
Note: canvas.width truncates fractions instead of rounding them. Assigning 333.9 gives 333. That is why you round yourself first.
Step 2: The browser places the box on screen
The browser does not use the width directly. It snaps the left edge and right edge to whole physical pixels, and the physical width is the difference.
| CSS position | × 1.25 (physical) | Snapped to nearest whole pixel | |
|---|---|---|---|
| Left edge | 10.64 | 13.3 | 13 |
| Right edge | 277.36 | 346.7 | 347 |
physical width = 347 − 13 = 334Step 3: The two numbers disagree
- Your buffer: 333
- Physical pixels the browser gives the box: 334
The browser must stretch a 333-pixel buffer across 334 physical pixels (about 0.3%). Buffer pixels no longer line up one-to-one with screen pixels, so thin lines and small text get blended and look soft.
Same box, different position
Move the left edge from 10.64 to 10.4. The width does not change.
| CSS position | × 1.25 | Snapped | |
|---|---|---|---|
| Left edge | 10.4 | 13.0 | 13 |
| Right edge | 277.12 | 346.4 | 346 |
physical width = 346 − 13 = 333Now the buffer (333) matches the physical width (333), and the result is sharp.
So one box with one width gets 333 or 334 physical pixels depending only on where it sits. That is why Math.round(width × dpr) is not fully accurate: it only looks at the width.
The precise fix
Ask the browser what it actually allocated:
ro.observe(canvas, { box: 'device-pixel-content-box' });
// in the callback: entry.devicePixelContentBoxSize[0].inlineSize
| Box position | Reported physical width |
|---|---|
| left edge at 10.64 | 334 |
| left edge at 10.4 | 333 |
Assign that value to canvas.width and the buffer always matches the screen.
A fuller version with a fallback:
const ro = new ResizeObserver(([entry]) => {
const css = entry.contentBoxSize[0];
const dev = entry.devicePixelContentBoxSize?.[0];
const w = dev ? dev.inlineSize : Math.round(css.inlineSize * devicePixelRatio);
const h = dev ? dev.blockSize : Math.round(css.blockSize * devicePixelRatio);
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w; // clears the canvas and resets the transform
canvas.height = h;
}
ctx.setTransform(w / css.inlineSize, 0, 0, h / css.blockSize, 0, 0);
draw();
});
try {
ro.observe(canvas, { box: 'device-pixel-content-box' });
} catch {
ro.observe(canvas, { box: 'content-box' }); // Safari
}Math.round guesses the physical pixel count from the width alone. devicePixelContentBoxSize asks the browser for the answer it actually used. The first is right only when the position happens to cooperate; the second is always right.
In practice the error is one pixel, well under 1% of scaling for most canvases, so it only matters for pixel-sensitive content such as pixel art, dense 1px lines, or WebGL.
Case 3: Responsive layouts
If the canvas is styled width: 100%, its CSS size changes as the window resizes, but the buffer size doesn't follow. The image gets stretched.
Example(DPR = 1)
canvas { width: 100%; height: 200px; }- When the container is 800px, you set the buffer to 800×200. It lines up 1:1, so the image looks normal.
- The window shrinks and the container becomes 400px: the CSS size becomes 400×200, but the buffer is still 800×200, so it gets squeezed horizontally by half and the circle turns into an ellipse.
- The window grows and the container becomes 1200px: the 800 pixels are stretched to 1200, so the image looks wide and blurry.
const ro = new ResizeObserver(() => {
const rect = canvas.getBoundingClientRect();
const dpr = devicePixelRatio;
const w = Math.round(rect.width * dpr);
const h = Math.round(rect.height * dpr);
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
draw();
});
ro.observe(canvas);The fix is a ResizeObserver on the canvas itself rather than window.resize, because the canvas can change size for reasons unrelated to the window: a collapsing sidebar, a flex layout shift, and so on.
Putting it together
ResizeObserver with device-pixel-content-box covers most of this. It also fires on zoom, so it handles Case 1 too. Safari falls back to Math.round plus matchMedia.
type DrawFn = (
ctx: CanvasRenderingContext2D,
info: { width: number; height: number; scale: number },
) => void;
export function setupHiDPICanvas(canvas: HTMLCanvasElement, draw: DrawFn) {
const ctx = canvas.getContext('2d')!;
let cssWidth = 0;
let cssHeight = 0;
let pixelWidth = 0;
let pixelHeight = 0;
let hasDevicePixelBox = false;
let mql: MediaQueryList | null = null;
function render() {
if (cssWidth === 0 || cssHeight === 0) return;
// Only assign when the size changed, to avoid needless clears and resets
if (canvas.width !== pixelWidth) canvas.width = pixelWidth;
if (canvas.height !== pixelHeight) canvas.height = pixelHeight;
// Use the real pixel ratio rather than dpr; rounding makes them differ slightly
ctx.setTransform(pixelWidth / cssWidth, 0, 0, pixelHeight / cssHeight, 0, 0);
ctx.clearRect(0, 0, cssWidth, cssHeight);
draw(ctx, { width: cssWidth, height: cssHeight, scale: pixelWidth / cssWidth });
}
function fallbackPixelSize() {
const dpr = window.devicePixelRatio || 1;
pixelWidth = Math.round(cssWidth * dpr);
pixelHeight = Math.round(cssHeight * dpr);
}
const ro = new ResizeObserver(([entry]) => {
const css = entry.contentBoxSize[0];
cssWidth = css.inlineSize;
cssHeight = css.blockSize;
const dp = entry.devicePixelContentBoxSize?.[0];
if (dp) {
hasDevicePixelBox = true;
pixelWidth = dp.inlineSize;
pixelHeight = dp.blockSize;
} else {
fallbackPixelSize();
}
render();
});
try {
ro.observe(canvas, { box: 'device-pixel-content-box' });
} catch {
// Browsers that don't support this option throw
ro.observe(canvas, { box: 'content-box' });
}
// On the fallback path, ResizeObserver won't fire if DPR changes but CSS size doesn't
function onDprChange() {
if (!hasDevicePixelBox) {
fallbackPixelSize();
render();
}
listenDpr();
}
function listenDpr() {
mql = matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
mql.addEventListener('change', onDprChange, { once: true });
}
listenDpr();
return {
redraw: render,
destroy() {
ro.disconnect();
mql?.removeEventListener('change', onDprChange);
},
};
}Inside draw, you always work in CSS pixel coordinates and never think about DPR again:
const { redraw, destroy } = setupHiDPICanvas(canvas, (ctx, { width, height }) => {
ctx.fillStyle = '#0a84ff';
ctx.fillRect(20, 20, width - 40, 40);
});Bonus: why 1px lines are still blurry
Even with DPR handled, 1px lines may still look soft. Canvas strokes are centered on the coordinate. At DPR 1, a 1px line at x = 10 covers 9.5 to 10.5, straddling two pixels, so each is painted at half opacity.
The usual advice is "add 0.5," but that only works at integer DPRs. At 1.25 or 1.5, 10.5 still lands on a fractional physical pixel. A more general approach is to align in physical pixel space and convert back to CSS pixels:
function crispLine(x: number, lineWidth: number, scale: number) {
const devicePx = Math.max(1, Math.round(lineWidth * scale));
const offset = devicePx % 2 === 1 ? 0.5 : 0;
return {
x: (Math.round(x * scale) + offset) / scale,
lineWidth: devicePx / scale,
};
}I drew a 1px vertical line at x = 10 and checked which pixel columns were painted, and at what opacity:
| DPR | Drawn directly | With crispLine |
|---|---|---|
| 1 | 2 columns, ~50% each | 1 column, 100% |
| 1.25 | 3 columns (14%, 100%, 11%) | 1 column, 100% |
| 1.5 | 2 columns, ~75% each | 1 column, 100% |
Takeaways
- A canvas has two sizes; blur comes from them not matching.
- Setting
canvas.widthclears the canvas and resets the context. Reset the transform and redraw afterwards. - A
matchMediaDPR listener fires only once. Re-register it. Math.roundcan be off by a pixel. UsedevicePixelContentBoxSizewhere it's supported.- Observe the canvas itself, not just
window.resize. - "Add 0.5" only holds at integer DPRs.