Hui
Dev

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 HuiChing Hui

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 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:

  1. Drawing buffer size: how many pixels the canvas actually holds in memory, set by canvas.width / canvas.height. The default is 300 × 150.
  2. 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, writing ctx.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); d

266.72 × 1.25 = 333.4 canvas.width = Math.round(333.4) = 333

ComputedWithout Math.roundWith Math.round
333.4333333
333.6333334
333.9333334

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 edge10.6413.313
Right edge277.36346.7347
physical width = 347 − 13 = 334

Step 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.25Snapped
Left edge10.413.013
Right edge277.12346.4346
physical width = 346 − 13 = 333

Now 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 positionReported physical width
left edge at 10.64334
left edge at 10.4333

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:

DPRDrawn directlyWith crispLine
12 columns, ~50% each1 column, 100%
1.253 columns (14%, 100%, 11%)1 column, 100%
1.52 columns, ~75% each1 column, 100%

Takeaways

  • A canvas has two sizes; blur comes from them not matching.
  • Setting canvas.width clears the canvas and resets the context. Reset the transform and redraw afterwards.
  • A matchMedia DPR listener fires only once. Re-register it.
  • Math.round can be off by a pixel. Use devicePixelContentBoxSize where it's supported.
  • Observe the canvas itself, not just window.resize.
  • "Add 0.5" only holds at integer DPRs.

References