A blurry canvas is almost always one of four mistakes, and each produces a different artefact. Naming the artefact identifies the mistake, which is faster than re-deriving the whole pipeline.
Problem / Scenario Context
A sparkline sits in a dashboard card that resizes with the layout. It is drawn once at startup and redrawn from a ResizeObserver. On a standard display it looks correct; on a laptop with a pixel ratio of 2 the line is soft and the axis labels have coloured fringes, and after dragging the card's pane the chart is drawn at half scale in the top-left corner.
Three symptoms, one function. The chart is soft because the backing store was never scaled for the display. It is half-scale because the context transform was applied once at startup and lost. The fringes are subpixel positioning on a grid that does not line up with device pixels.
Mechanics Explanation
Four things must happen, in this order, every time the container's size changes.
Measure the container's box — from the entry, not from a fresh getBoundingClientRect(), because the entry already carries the measurement the browser computed.
Resize the backing store by writing canvas.width and canvas.height in device pixels. This assignment clears the surface and resets every piece of context state, including the transform.
Re-apply the transform with ctx.setTransform(ratio, 0, 0, ratio, 0, 0), so drawing code can keep working in CSS units.
Redraw, because the surface is now blank.
Skipping step two gives blur. Skipping step three gives a half-scale drawing. Doing step four before steps two and three gives a chart that vanishes on the next resize. Doing step one with a DOM read instead of the entry gives a forced layout inside the callback.
Comparison Table: Sizing Sources
| Source | Value | Verdict |
|---|---|---|
entry.contentRect.width |
CSS pixels, fractional | correct input; multiply by the ratio |
entry.devicePixelContentBoxSize[0].inlineSize |
device pixels, integer | best, where supported |
canvas.getBoundingClientRect() |
CSS pixels | correct value, forced layout to obtain it |
canvas.clientWidth |
CSS pixels, rounded | forced layout, and loses the fraction |
window.innerWidth |
viewport | wrong element entirely |
Minimal Reproducible Example
// Blurry: the backing store never changes
const ro = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
draw(ctx, width, height); // canvas.width is still the default 300
});
ro.observe(shell);
Production-Safe Solution
const shell = document.querySelector<HTMLElement>('.chart-shell')!;
const canvas = shell.querySelector('canvas')!;
const ctx = canvas.getContext('2d')!;
function backing(entry: ResizeObserverEntry): { w: number; h: number; r: number } {
const dpr = window.devicePixelRatio || 1;
const dp = entry.devicePixelContentBoxSize?.[0];
if (dp) return { w: dp.inlineSize, h: dp.blockSize, r: dpr };
const cb = entry.contentBoxSize?.[0];
const cssW = cb ? cb.inlineSize : entry.contentRect.width;
const cssH = cb ? cb.blockSize : entry.contentRect.height;
return { w: Math.round(cssW * dpr), h: Math.round(cssH * dpr), r: dpr };
}
let queued = false;
let latest: ResizeObserverEntry | null = null;
const ro = new ResizeObserver((entries) => {
latest = entries[entries.length - 1];
if (queued) return;
queued = true;
requestAnimationFrame(() => {
queued = false;
const entry = latest!;
const { w, h, r } = backing(entry);
if (w === 0 || h === 0) return; // collapsed container — nothing to draw
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w; // 2. clears the surface and the transform
canvas.height = h;
ctx.setTransform(r, 0, 0, r, 0, 0); // 3. restore CSS units
}
draw(ctx, entry.contentRect.width, entry.contentRect.height); // 4. always redraw
});
});
try { ro.observe(shell, { box: 'device-pixel-content-box' }); }
catch { ro.observe(shell); }
Two guards earn their place. The zero check prevents assigning a zero backing store when the container sits inside a collapsed accordion, which leaves an unusable context. The dimension comparison skips the clear-and-transform when only the unobserved axis changed, so a container that grows in height does not needlessly clear a chart whose width is unchanged — but the redraw still runs, because the drawing may depend on height.
A devicePixelRatio change also needs handling, because dragging a window between monitors does not resize anything:
let dprWatcher: MediaQueryList | null = null;
function watchRatio(): void {
dprWatcher?.removeEventListener('change', onRatioChange);
dprWatcher = matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
dprWatcher.addEventListener('change', onRatioChange, { once: true });
}
function onRatioChange(): void {
canvas.width = 0; // force the comparison above to see a difference
watchRatio();
shell.dispatchEvent(new Event('force-redraw'));
}
watchRatio();
Retaining the Previous Frame
There is one refinement worth knowing about, because the naive resize sequence has a visible weakness on a slow drag: between clearing the surface and finishing the redraw, the canvas is blank. For a cheap chart this is a single frame and invisible. For an expensive one — thousands of marks, or a scene that has to be recomputed — it reads as flicker.
The fix is to keep the old bitmap and blit it while the real drawing catches up. Before resizing, copy the current surface into an offscreen canvas; after resizing, draw that copy stretched into the new box, then perform the real redraw on top. The stretched copy is wrong in detail — it is exactly the blur this page is about — but it is only on screen for one frame, and a slightly soft chart for 16 milliseconds is far less noticeable than a white rectangle.
const scratch = document.createElement('canvas');
function resizeKeepingFrame(w: number, h: number, r: number): void {
scratch.width = canvas.width; scratch.height = canvas.height;
scratch.getContext('2d')!.drawImage(canvas, 0, 0);
canvas.width = w; canvas.height = h;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.drawImage(scratch, 0, 0, scratch.width, scratch.height, 0, 0, w, h);
ctx.setTransform(r, 0, 0, r, 0, 0);
}
Use it only where a measurement says the redraw is expensive. For most charts the extra copy costs more than the flicker it prevents, and the plain sequence is the better default.
Verification Steps
- Check the ratio in the console:
canvas.width / canvas.getBoundingClientRect().widthshould equaldevicePixelRatio. - Check the transform survived:
ctx.getTransform().ashould equal the ratio after a resize, not 1. - Zoom the browser to 150% and confirm the chart stays sharp — browser zoom changes
devicePixelRatio. - Drag between monitors with different densities and confirm the chart re-sharpens.
- Collapse the container and reopen it; the chart must return rather than staying blank.
Common Mistakes to Avoid
- Setting
canvas.style.widthinstead ofcanvas.width. The style property changes the display box; only the attribute changes the pixel grid. - Applying
setTransformonce at startup. The first resize discards it silently. - Calling
getBoundingClientRect()inside the callback. The entry already has the measurement and asking again risks a forced layout. - Observing the canvas itself. Writing its width can change its own box, which is the loop-limit error by another route.
FAQ
Why is the chart sharp but drawn at half size?
The backing store was scaled but the context transform was not re-applied. Writing to canvas.width resets every piece of context state, including the transform, so a setTransform call made once at startup disappears on the first resize and all subsequent drawing lands at one over the pixel ratio.
Do I need to redraw if only the height changed?
Yes, unless the drawing genuinely ignores height. The backing-store assignment can be skipped when neither dimension changed, but the redraw should run whenever the entry arrives, because the drawing may lay out differently in the new box even at the same width.
Does browser zoom change devicePixelRatio?
Yes. Zooming to 150 per cent changes the ratio, and a canvas whose backing store was sized at the old ratio becomes soft. Watching a resolution media query and forcing a resize when it changes covers both zoom and moving a window between displays.
What happens if the container collapses to zero?
Assigning a zero width or height to the canvas is legal and produces a surface that cannot be drawn to. Guard the callback with an early return on zero, and rely on the observer firing again when the container reopens to restore the chart.
Related
- Using devicePixelContentBoxSize for Crisp Canvas — why the exact device-pixel box beats multiplying yourself
- Debouncing Chart Redraws on Container Resize — when the coalescing above is worth extending
- Fixing ResizeObserver Loop Limit Exceeded — what happens if you observe the canvas instead of the shell
↑ Back to Responsive Canvas & Chart Resizing