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.

Display Size and Backing Store Are Two Different NumbersA canvas shown twice. On the left the backing store is 600 by 300 while CSS stretches the element to 600 CSS pixels on a 2x display, so the bitmap is scaled up and the result is blurred. On the right the backing store is 1200 by 600 and the context is scaled by the pixel ratio, so one drawn unit maps to two device pixels and the result is sharp. A caption states which value each side is set by.backing store left at defaultcanvas.width = 600CSS width: 600px, DPR 21 stored pixel stretched over 2 device pixelshairlines soften, text edges fringebacking store sized for the devicecanvas.width = 1200ctx.setTransform(2,0,0,2,0,0)1 drawn unit maps to exactly 2 device pixelsdrawing code still uses CSS units throughoutThe attributes set the grid; the stylesheet sets the box.CSS alone cannot make a canvas sharp, and attributes alone cannot make it responsive.

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

HTML
<div class="chart-shell">          <!-- observed -->
  <canvas class="chart-canvas"></canvas>   <!-- resized, never observed -->
</div>
CSS
.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

TypeScript
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

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

TypeScript
try {
  observer.observe(shell, { box: 'device-pixel-content-box' });
} catch {
  observer.observe(shell);                 // older engines: the fallback path in backingSize() handles it
}

Observe the Shell, Not the CanvasTwo wiring diagrams. In the first, the observer watches the canvas and the callback writes the canvas width attribute, which changes the canvas box and re-triggers the same observer, forming a cycle. In the second, the observer watches a wrapper element whose size is decided by the page layout, and the callback writes to the canvas inside it, so no edge returns to the observed element.observing the canvas — a cycleobserve(canvas)canvas.width = wthe write changes the canvas's own box — same observer fires againobserving the shell — no return edgeobserve(shell)canvas.width = wthe shell's size is decided by page layout, not by the canvas

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.

Keeping Text Out of the BitmapA chart split across two layers. The canvas holds only the data marks, while axes, labels, the legend and the title are rendered as HTML positioned over it. A resize then redraws the data without re-typesetting the chart, and the text remains selectable, translatable and reachable by a screen reader.canvas: data marks onlyaxislabels + legend as HTMLwhat the split buysa resize redraws data, not textlabels stay selectable and translatablea screen reader can reach the legendand the redraw gets measurably cheaperPut only the marks in the bitmap; leave the words as wordsText rendered into a canvas is invisible to selection, translation, search and assistive technology.

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 setTransform was 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 exceeded in the console. The canvas is being observed rather than its shell.
  • observe() throws. The device-pixel-content-box option 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 false when it should not, or draw() is not being called on the truthy branch.
  • It blurs only after dragging to a second monitor. devicePixelRatio changed 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.


↑ Back to Implementation Patterns for Viewport & Resize Tracking