A canvas is the one element where "responsive" cannot be delegated to CSS. Every other element scales its content for free; a canvas has a fixed pixel grid that CSS will happily stretch, and the result is a chart whose axis labels look like they were printed on a photocopier. Getting this right means holding two sizes in mind at once and keeping them in sync from a ResizeObserver callback.
Concept Framing
A <canvas> element has a display size and a backing-store size, and they are set through completely different mechanisms.
The display size is the CSS box — width: 100% in a stylesheet, resolved by layout like any other element. The backing-store size is the width and height attributes, which define the pixel grid the 2D context draws into. If the two disagree, the browser scales the bitmap to fit the box, and scaling up is what produces blur.
On a device with a pixel ratio of 2, a canvas occupying a 600 CSS-pixel-wide box needs a 1200-pixel backing store to look sharp. Nothing does that automatically. And because the backing store must be an integer while the CSS box may be fractional after flexbox distributes leftover space, the multiplication has to be done carefully — which is exactly what devicePixelContentBoxSize exists to remove the guesswork from.
There is a second trap layered on top. Writing to canvas.width resets the entire drawing surface, clears it, and — because the attribute participates in the element's intrinsic sizing — can change the element's own layout box. An observer watching the canvas therefore risks retriggering itself, which is the loop-limit error arriving by a slightly different route. Observing a wrapper element removes the feedback path.
Spec / Signature Reference Table
| Source | Units | Availability | Use for |
|---|---|---|---|
entry.contentRect.width |
CSS pixels, fractional | Everywhere | The size to pass to layout-aware drawing code |
entry.contentBoxSize[0].inlineSize |
CSS pixels, fractional | Everywhere | The same value, in the modern entry shape |
entry.devicePixelContentBoxSize[0].inlineSize |
Device pixels, integer | Narrower support | The backing-store width, exactly |
window.devicePixelRatio |
Ratio | Everywhere | The fallback multiplier, and the context transform |
canvas.width / canvas.height |
Device pixels, integer | Everywhere | Writing resets and clears the surface |
ctx.setTransform(r, 0, 0, r, 0, 0) |
— | Everywhere | Restores CSS units for drawing code after a resize |
The reason devicePixelContentBoxSize is worth the capability check is subpixel layout. A flex child can end up 300.5 CSS pixels wide; multiplying by a ratio of 2 gives 601, but the element actually covers 601 device pixels only if the layout rounded that way. The device-pixel box tells you what the browser decided, instead of asking you to guess.
Step-by-Step Implementation
1. Structure the markup so the observed element is not the canvas
<div class="chart-shell"> <!-- observed -->
<canvas class="chart-canvas"></canvas> <!-- resized, never observed -->
</div>
.chart-shell { position: relative; width: 100%; aspect-ratio: 16 / 9; }
.chart-canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
display: block on the canvas removes the inline-element baseline gap that otherwise makes the shell a few pixels taller than the canvas and produces a slow drift each resize.
2. Resize the backing store from the container's box
function backingSize(entry: ResizeObserverEntry): { w: number; h: number; ratio: number } {
const dpr = window.devicePixelRatio || 1;
// The exact device-pixel box, when the browser reports it
const dp = entry.devicePixelContentBoxSize?.[0];
if (dp) return { w: dp.inlineSize, h: dp.blockSize, ratio: dpr };
// Fallback: CSS box times the ratio, rounded the way the compositor would
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), ratio: dpr };
}
function resizeCanvas(canvas: HTMLCanvasElement, entry: ResizeObserverEntry): boolean {
const { w, h, ratio } = backingSize(entry);
if (canvas.width === w && canvas.height === h) return false; // nothing changed — skip the clear
canvas.width = w; // resets and CLEARS the surface
canvas.height = h;
const ctx = canvas.getContext('2d')!;
ctx.setTransform(ratio, 0, 0, ratio, 0, 0); // drawing code keeps using CSS units
return true;
}
The early return matters more than it looks. ResizeObserver fires for changes on either axis, so a container that grows only in height would otherwise clear and redraw a chart whose width — the thing the chart actually depends on — did not move.
3. Coalesce the redraw into one frame
let scheduled = false;
let latest: ResizeObserverEntry | null = null;
const observer = new ResizeObserver((entries) => {
latest = entries[entries.length - 1];
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
scheduled = false;
const entry = latest!;
if (resizeCanvas(canvas, entry)) draw(canvas, entry.contentRect.width, entry.contentRect.height);
});
});
observer.observe(shell, { box: 'device-pixel-content-box' });
Requesting the device-pixel-content-box is what makes devicePixelContentBoxSize populated at all — the field is absent unless you asked for that box. Browsers that do not recognise the option throw on observe(), so wrap it:
try {
observer.observe(shell, { box: 'device-pixel-content-box' });
} catch {
observer.observe(shell); // older engines: the fallback path in backingSize() handles it
}
Configuration Variants
| Situation | Box option | Redraw strategy |
|---|---|---|
| Static chart, occasional resize | default content-box |
Redraw on every change |
| Live chart with streaming data | device-pixel-content-box |
Resize on change; draw on your own animation loop |
| Chart in a draggable split pane | device-pixel-content-box |
rAF coalescing, plus the no-change early return |
| Print or export at fixed size | not observed | Resize once, deliberately, before rendering |
| WebGL rather than 2D | device-pixel-content-box |
Also update gl.viewport(); setTransform does not apply |
Edge Cases & Gotchas
The surface is cleared by the resize, not by you. Any drawing done before the width assignment is gone. Order is always: measure, resize, transform, draw.
setTransform must be re-applied after every resize. Writing canvas.width resets the context state including the transform, so a transform applied once at startup silently disappears on the first resize and every subsequent drawing lands at half scale.
Zero-size containers. A shell inside a collapsed accordion reports 0 × 0. Assigning zero to canvas.width is legal and produces an unusable context; guard with if (w === 0 || h === 0) return; and redraw when it reopens.
devicePixelRatio changes when a window moves between monitors. It is not a constant. Listening to matchMedia(\(resolution: ${devicePixelRatio}dppx)`)for achange` event, and re-running the resize path, covers the dual-monitor case that otherwise leaves the chart blurry after a drag between screens.
Fractional CSS sizes from flexbox. This is the case the device-pixel box was designed for, and the reason a hairline axis can look fine at one pane width and soft at another.
Third-party libraries that own the canvas. Some libraries recreate the element on resize(), which invalidates any reference you kept. Re-query the canvas after calling the library's resize method rather than caching it across calls.
Choosing Between Canvas, SVG and CSS
Before reaching for any of the machinery above, it is worth being deliberate about whether a canvas is the right surface at all — because two of the three alternatives make the resize problem disappear rather than solve it.
SVG scales for free. An inline SVG element carrying a viewBox and no fixed width is resolution-independent by construction: the browser re-rasterises it at whatever size the layout gives it, at whatever pixel density the display has, with no observer and no backing store. For anything up to a few thousand elements — most dashboards, most sparklines, most diagrams — this is simply the better choice, and the entire contents of this page become unnecessary. The cost appears only when element count grows: every mark is a DOM node with its own style resolution and hit-testing, and a scatter plot with fifty thousand points will make the browser struggle in a way a canvas would not.
CSS handles the simple cases. A progress bar, a gauge, a stacked bar, a heat cell — these are boxes with backgrounds, and expressing them as elements gets responsiveness, transitions, accessibility and text selection thrown in. Reaching for a canvas to draw a rectangle is a common over-correction.
Canvas earns its complexity at scale, and only there. Tens of thousands of marks, per-frame animation over a live data stream, pixel-level effects, or anything that must composite off the main thread through an OffscreenCanvas and a worker. Those are the cases where the DOM cost of SVG is real and the resize plumbing above is a fair price.
A practical rule: start with SVG, move to canvas when a profile shows style or layout cost dominated by mark count, and treat the move as a deliberate trade of convenience for throughput. If you do move, keep the axes, legend and labels as HTML or SVG layered over the canvas — those are the parts that benefit most from being real elements, and keeping them out of the bitmap means a resize only has to redraw the data, not re-typeset the chart.
Framework Integration Patterns
React. The shell gets a callback ref that registers it with a shared observer; the drawing effect depends on the data, not on the size. Keeping size out of state avoids a re-render per frame during a drag — the size only ever needs to reach the imperative draw call.
Vue. A useCanvasSize(shellRef) composable returning readonly width, height and pixelRatio refs fits the composable conventions; the component watches those and calls its own draw function.
Angular. A directive on the shell element, running outside the zone, emitting a single canvasResize output with the computed backing size — the same zone discipline that keeps scroll-driven callbacks from triggering change detection.
Debugging Checklist
- Everything is slightly soft. The backing store is not being multiplied by the pixel ratio, or
setTransformwas applied once and lost to a later resize. - The chart is sharp but half the size. The backing store was scaled and the transform applied twice — once at startup and once per resize, compounding.
ResizeObserver loop limit exceededin the console. The canvas is being observed rather than its shell.observe()throws. Thedevice-pixel-content-boxoption is unsupported; wrap it in a try and fall back.- The chart is blank after a resize. The surface was cleared and nothing redrew it — the early-return guard is returning
falsewhen it should not, ordraw()is not being called on the truthy branch. - It blurs only after dragging to a second monitor.
devicePixelRatiochanged and nothing re-ran the resize path.
FAQ
Why is my canvas blurry on a high-DPI screen?
The canvas has two sizes: the CSS box it occupies and the pixel grid it draws into. If the width and height attributes are left at their defaults while CSS stretches the element, the browser scales a small bitmap up to fill the box, and the result is soft. Set the attributes to the CSS size multiplied by the device pixel ratio, then scale the drawing context by the same factor.
Should I observe the canvas or its container?
The container. Writing to a canvas's width or height attribute resets its backing store and can change its own layout box, so an observer watching the canvas can retrigger itself. Observing a wrapper element removes the feedback path entirely, which is the same reasoning behind fixing the ResizeObserver loop error.
What does devicePixelContentBoxSize give me that contentBoxSize does not?
The exact integer number of device pixels the content box occupies, already accounting for the device pixel ratio and for subpixel layout rounding. Multiplying contentBoxSize by devicePixelRatio yourself is an approximation that can land half a pixel off, which shows up as a faint blur on hairlines and text.
Do I need to debounce the redraw while a pane is being dragged?
Usually not. ResizeObserver already delivers at most once per frame, so a debounce only adds latency. What is worth doing is coalescing into a requestAnimationFrame so the resize and the redraw happen in the same frame, and skipping the redraw entirely when the computed pixel size has not actually changed.
Why does my chart library ignore its container's new size?
Most libraries listen for the window resize event, which does not fire when only a panel or sidebar changes width. The fix is to observe the container yourself and call the library's own resize or update method from the callback, rather than waiting for a window event that will never arrive.
Related
- Resizing a Canvas with ResizeObserver Without Blurring — the minimal correct resize path
- Using devicePixelContentBoxSize for Crisp Canvas — why the exact device-pixel box beats multiplying yourself
- Debouncing Chart Redraws on Container Resize — when coalescing helps and when it just adds lag
- Fixing Chart Libraries That Ignore Container Resize — adapting a library that only listens to window
- Element Resize Detection Patterns — the shared-observer foundation these pages build on
↑ Back to Implementation Patterns for Viewport & Resize Tracking