Keeping Canvas Text Crisp at Any Zoom

Your Canvas labels are sharp at the default view but turn fuzzy the moment the user zooms or pans into the chart.

The cause is almost always that the zoom multiplies a transform which then stretches already-rasterized glyphs, or that the chart caches a rendered layer as a bitmap and scales that bitmap up. Text is a sub-pixel feature; any scale applied after rasterization blurs it. This guide is part of Text & Label Rendering in Charts, and it is the zoom-time companion to Fixing blurry Canvas on high-DPI displays — that page fixes the static DPR mismatch; this one keeps text crisp while the scale changes under interaction, and assumes you already handle DPR correctly.

Diagnostic checklist

Scaling a bitmap versus re-rasterizing text Left path scales a cached glyph bitmap and blurs. Right path keeps text in world coordinates and re-runs fillText at the new scale so glyphs stay crisp. Scale a cached bitmap fillText once → bitmap drawImage scaled by zoom blur: pixels interpolated Re-rasterize at scale map world → screen coords reset transform, fillText fresh crisp: glyphs re-hinted
Position moves with the zoom, but glyphs must be drawn fresh at the final scale. Scaling a cached raster interpolates pixels; re-running fillText re-hints the type at the true size.
Two canvases rendering the same chart on a high-DPI display: the left, whose backing store equals its CSS size, is visibly soft; the right, scaled by devicePixelRatio, is sharp.
A real 2× capture: identical drawing code, but the left canvas leaves its backing store at CSS size and softens, while the right scales the backing store by devicePixelRatio and stays crisp.
Clustered scatter sample Three seeded gaussian clusters of points on a shared pair of axes. xy
A representative point cloud of the kind canvas text crisp at any zoom operates on — three clusters that a renderer must place, pick, or pan without dropping frames.

Broken vs fixed

// ❌ BROKEN: text is baked into the same transform that the zoom scales.
function drawZoomed(ctx: CanvasRenderingContext2D, zoom: number, label: string) {
  ctx.save();
  ctx.scale(zoom, zoom);       // scales EVERYTHING drawn after it, including glyphs
  ctx.font = "12px Inter";     // 12px in world units → 12*zoom px of blurred raster
  ctx.fillText(label, 100, 50);
  ctx.restore();
}
// ✅ FIXED: transform positions only; glyphs are drawn at a fixed screen size.
interface ViewTransform { scale: number; tx: number; ty: number; }

function drawCrisp(
  ctx: CanvasRenderingContext2D,
  view: ViewTransform,
  worldX: number,
  worldY: number,
  label: string,
) {
  // Map the world anchor to a SCREEN pixel, then draw text un-transformed.
  const screenX: number = worldX * view.scale + view.tx;
  const screenY: number = worldY * view.scale + view.ty;

  ctx.setTransform(1, 0, 0, 1, 0, 0);   // reset: draw in device/screen space
  ctx.font = "12px Inter";               // constant on-screen size at every zoom
  ctx.textBaseline = "alphabetic";
  // PERF: snap the baseline to a whole pixel so stems land on the grid.
  ctx.fillText(label, Math.round(screenX), Math.round(screenY));
  // A11Y: label text must also live in an offscreen table; canvas pixels are opaque.
}

The principle is to separate what the zoom scales from what it does not. Positions are in world coordinates and must scale with the zoom, so a data point that is at world (100, 50) moves across the screen as you zoom. But the glyph itself should render at a constant on-screen point size — 12px at 1× and 12px at 4× — because label legibility should not depend on zoom level, and, crucially, because drawing at the final screen size lets the rasterizer hint the type freshly instead of interpolating a stretched bitmap. Apply the transform to the anchor, reset the transform, then fillText at the screen coordinate.

If you deliberately want text to grow with zoom (a map label that scales with the terrain), you still re-rasterize rather than scale a bitmap: multiply the font size by the zoom (ctx.font = \${12 * view.scale}px Inter``) and draw at the un-transformed anchor. The glyph is then rendered once at its true final size, crisp, rather than drawn small and stretched.

Step-by-step fix

// Full per-frame draw: marks scale with the view, text re-rasterizes crisply.
function renderFrame(
  ctx: CanvasRenderingContext2D,
  view: ViewTransform,
  points: Array<{ x: number; y: number; label: string }>,
  dpr: number,
) {
  // 1) Marks: apply DPR + view transform, draw geometry in world space.
  ctx.setTransform(dpr * view.scale, 0, 0, dpr * view.scale, dpr * view.tx, dpr * view.ty);
  for (const p of points) {
    ctx.beginPath();
    ctx.arc(p.x, p.y, 3 / view.scale, 0, Math.PI * 2); // keep dot radius constant on screen
    ctx.fill();
  }
  // 2) Labels: reset to device space, draw each glyph fresh at a fixed size.
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  ctx.font = "12px Inter";
  for (const p of points) {
    const sx = p.x * view.scale + view.tx;
    const sy = p.y * view.scale + view.ty;
    ctx.fillText(p.label, Math.round(sx) + 6, Math.round(sy));
  }
}

Verification

// Assert the text draw pass carries no residual view scale (only DPR).
function assertTextTransform(ctx: CanvasRenderingContext2D, dpr: number): void {
  const t: DOMMatrix = ctx.getTransform();
  console.assert(t.a === dpr && t.d === dpr, `text pass scale ${t.a} should equal dpr ${dpr}`);
}

In DevTools, zoom the chart to its maximum and screenshot a label, then zoom to 1× and screenshot the same label; the glyph edges should show the same crisp anti-aliasing at both scales, not a soft halo at high zoom. Toggling device emulation between DPR 1 and 2 should keep text sharp at every zoom level.

Why re-rasterizing beats caching a text layer

It is tempting to treat static labels as a cacheable layer — render them once into an offscreen canvas, then drawImage that layer each frame to skip the per-frame fillText calls. For geometry this is a sound optimization. For text it is a trap, because the cached layer is a bitmap fixed at one resolution, and the instant the zoom scales it the glyphs interpolate and blur exactly as if you had never fixed the DPR. The cost you are trying to avoid — re-running fillText — is small: text rasterization is heavily optimized in every browser, and a few hundred labels redraw in well under a millisecond. The blur you introduce by caching is large and immediately visible. The rule is that geometry can be cached and transformed, but text must be re-rasterized at whatever scale it is finally displayed.

There is a middle path for extreme label counts: cache the glyph layer at device resolution for the current zoom level, and re-render the cache only when the zoom actually changes, not on every pan. During a pan the scale is constant, so the cached layer is valid and you only translate it — crisp, because translation by whole pixels does not interpolate. When the scale changes, invalidate and re-fillText the layer once at the new resolution. This gives you the caching win during the common interaction (panning) without the blur, because you never scale the cached pixels — you only ever translate them or regenerate them.

Two grids of one-pixel horizontal lines: the left, drawn at integer y coordinates, renders as soft grey because each line straddles two device rows; the right, offset by half a pixel, renders as crisp black.
One-pixel strokes captured at 2×: at integer coordinates (left) each line straddles two physical rows and averages to grey; nudged by half a pixel (right) it lands on a single row and stays black.

Edge cases & gotchas

  • Fractional pan offsets. Panning by a non-integer pixel offset shifts baselines off the grid and softens text even at a fixed scale. Round the translation to whole device pixels when text crispness matters more than sub-pixel smoothness of motion.
  • imageSmoothingEnabled hides the bug. Disabling smoothing makes a scaled text bitmap look jagged rather than blurry, which is not a fix — it is a different artefact. The only fix is to re-rasterize; leave smoothing at its default for photographic content.
  • Constant-radius marks. If dots or line widths are drawn under the view transform, they grow with zoom too. Divide their radius or line width by view.scale to keep them a constant on-screen size, as the reference frame does.
  • Font not loaded before first zoom. A web font that swaps in mid-interaction re-rasterizes at a different width and can shift label layout. Gate the first crisp render on document.fonts.ready.
  • Retina plus zoom compounding. The device pixel ratio and the view zoom both scale the backing store. Apply DPR to the transform and keep the view scale separate, or you double-count and either blur or overshoot the backing-store size.
  • High zoom exceeding max canvas size. Multiplying the backing store by both DPR and a large zoom can exceed the browser’s maximum canvas dimension. Cap the effective backing-store scale and rely on the view transform beyond that point.
Axis labels: colliding vs thinned One dense axis drawn twice — every tick labelled, then thinned and rotated. Same axis, two label strategiesJan 01Jan 08Jan 15Jan 22Jan 29Feb 05Feb 12Feb 19Feb 26Mar 05Mar 12Mar 19Every tick drawn → glyphs collideJan 01Jan 15Jan 29Feb 12Feb 26Mar 12Keep every Nth, rotate 35° → readable
Why canvas text crisp at any zoom matters: label every tick and glyphs collide; keep every Nth and rotate, and the axis stays legible.

Frequently Asked Questions

Why does my Canvas text blur when I zoom but not at the default view?

Because the zoom is scaling glyphs that were already rasterized. Text is a sub-pixel feature, so any scale applied after fillText interpolates the pixels and softens the edges, whereas at the default 1× view there is no post-raster scale to blur them. The fix is to draw text at its final on-screen size with the view transform reset, so the rasterizer hints the type fresh instead of stretching a bitmap.

Should Canvas labels grow when the chart zooms in?

Usually not. Chart chrome — axis ticks, value labels, legends — reads best at a constant on-screen point size regardless of zoom, so draw it at a fixed font size with the view transform reset. Reserve zoom-scaled text for cases like map labels that are conceptually part of the world, and even then set the font size to base * zoom and draw fresh rather than scaling a cached raster.

Is this the same problem as fixing blurry Canvas on Retina displays?

They share a cause — a scale applied after rasterization — but they are different fixes. The Retina fix is static: match the backing store to devicePixelRatio once so nothing is upscaled. This is dynamic: keep text crisp while the zoom factor changes under interaction by re-rasterizing glyphs at each new scale. You need the DPR fix in place first, then this on top for interactive zoom.