Responsive canvas layout and image export

Size the canvas through its wrapper, keep text crisp on high-DPI screens, export a PNG at a usable resolution, and print a report that is not a blurry screenshot.

How the canvas gets its size

<!-- The wrapper is what Chart.js measures. It must have a resolvable height. -->
<div class="chart-frame">
  <canvas id="revenue" role="img" aria-label="Revenue by quarter, values in the table below"></canvas>
</div>

<style>
  .chart-frame {
    position: relative;      /* the canvas is absolutely positioned inside */
    width: 100%;
    height: 360px;           /* the single source of the chart's height */
  }
  @media (min-width: 768px) {
    .chart-frame { height: 420px; }
  }
</style>
const chart = new Chart(canvas, {
  type: 'line',
  data,
  options: {
    responsive: true,                 // resize with the container
    maintainAspectRatio: false,       // allow the wrapper's height to win
    resizeDelay: 100,                 // debounce resize handling while dragging
    devicePixelRatio: window.devicePixelRatio,   // crisp text on retina
    layout: { padding: { top: 8, right: 16, bottom: 8, left: 8 } },
    scales: { x: { ticks: { autoSkip: true, maxTicksLimit: 8 } } }
  }
});

// Chart.js uses its own ResizeObserver on the parent. If the chart is inside a
// container that animates its size, tell it when to re-measure.
const panel = document.getElementById('panel');
panel.addEventListener('transitionend', () => chart.resize());

// Explicit sizing when the container cannot be measured (a hidden tab, a
// print view, an off-screen render for export).
chart.resize(900, 480);

// Reading the current size back
console.log(chart.width, chart.height, chart.currentDevicePixelRatio);
SituationBehaviourWhat to do
Wrapper has no heightThe chart grows on every resizeGive the wrapper an explicit height
Canvas is a flex childHeight collapses to the contentSize the wrapper, not the canvas
Chart inside a hidden tabInitialised at 0 by 0Call resize() when shown
A sidebar animates openThe chart lags behindCall resize() on transitionend
Very wide screenTicks crowd togethermaxTicksLimit and autoSkip
Full-screen mobilemaintainAspectRatio: true forces a shapeSet it to false and control the height in CSS
⚠️
Do not set width and height attributes on the canvas element and expect them to control the layout. Chart.js overwrites both to match the container, scaled by the device pixel ratio. The CSS size of the wrapper is the only reliable lever.

Exporting an image

// A PNG of the current render. The canvas is already at device resolution,
// so toDataURL needs no scaling for a screen-accurate capture.
const url = chart.toBase64Image('image/png', 1);
// or explicitly:
const url2 = chart.canvas.toDataURL('image/png');

// For print, capture at a higher resolution by temporarily resizing.
function exportHighRes(chart, { pixelRatio = 3, width = 1200, height = 640 } = {}) {
  const originalWidth = chart.width;
  const originalHeight = chart.height;
  const originalRatio = chart.currentDevicePixelRatio;

  chart.options.devicePixelRatio = pixelRatio;
  chart.resize(width, height);
  const url = chart.toBase64Image('image/png', 1);

  // restore
  chart.options.devicePixelRatio = originalRatio;
  chart.resize(originalWidth, originalHeight);
  return url;
}

// White background: a transparent PNG looks black in some viewers and in print.
function exportWithBackground(chart) {
  const { canvas } = chart;
  const copy = document.createElement('canvas');
  copy.width = canvas.width;
  copy.height = canvas.height;
  const ctx = copy.getContext('2d');
  ctx.fillStyle = '#ffffff';
  ctx.fillRect(0, 0, copy.width, copy.height);
  ctx.drawImage(canvas, 0, 0);
  return copy.toDataURL('image/png');
}

function download(dataUrl, filename) {
  const link = document.createElement('a');
  link.href = dataUrl;
  link.download = filename;
  link.click();
}

download(exportWithBackground(chart), 'revenue.png');
// Printing: match the paper size instead of scaling a small canvas.
window.addEventListener('beforeprint', () => {
  chart.options.devicePixelRatio = 3;
  chart.resize(1000, 520);
  chart.update('none');                 // no animation while printing
});

window.addEventListener('afterprint', () => {
  chart.options.devicePixelRatio = window.devicePixelRatio;
  chart.resize();
  chart.update('none');
});
  • toBase64Image and toDataURL both read the canvas at its current pixel size, so resolution is decided by the resize, not by the encoding call.
  • Resizing a chart re-runs the layout and the plugins. Capture after the resize completes — a synchronous resize() is enough, but an animated update() is not.
  • The exported image includes the legend, the title and any inline plugin drawing. If the exports look cluttered, disable the toolbox-like extras only for the export pass and restore them afterwards.
  • A canvas is tainted by cross-origin images drawn without CORS headers, and a tainted canvas makes toDataURL throw. If any image is drawn into the chart, load it with crossOrigin = 'anonymous' and have the server send the header.

Print stylesheets and small screens

/* Print: give the chart the page width and a fixed height. */
@media print {
  .chart-frame {
    height: 380px !important;
    break-inside: avoid;          /* keep the chart on one page */
    page-break-inside: avoid;     /* older engines */
  }
  .no-print { display: none !important; }
  /* Do not hide the chart: a canvas prints fine, unlike an iframe. */
}

/* Small screens: fewer ticks, a shorter chart, and no legend clutter. */
@media (max-width: 480px) {
  .chart-frame { height: 260px; }
}

/* Respect reduced motion by relying on the option, not on CSS. Chart.js
   reads its own animation configuration, so set it in JavaScript. */
// Adapting the option to the viewport at construction time
const isSmall = window.matchMedia('(max-width: 480px)').matches;

const chart = new Chart(canvas, {
  type: 'line',
  data,
  options: {
    responsive: true,
    maintainAspectRatio: false,
    plugins: {
      legend: { display: !isSmall, position: 'bottom' },
      title: { display: !isSmall, text: 'Revenue by quarter' }
    },
    scales: {
      x: { ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: isSmall ? 4 : 10 } },
      y: { ticks: { maxTicksLimit: isSmall ? 4 : 8 } }
    }
  }
});

// React to a breakpoint change without recreating the chart
const media = window.matchMedia('(max-width: 480px)');
media.addEventListener('change', (event) => {
  chart.options.plugins.legend.display = !event.matches;
  chart.options.scales.x.ticks.maxTicksLimit = event.matches ? 4 : 10;
  chart.update('none');
});
GoalSettingWhy
Print at usable detaildevicePixelRatio: 3 plus a resizeThe canvas is a raster
No page break inside a chartbreak-inside: avoidA split canvas is unreadable
Readable on a phoneFewer ticks, shorter frameLabels crowd before the chart does
Smooth resizeresizeDelay: 100Prevents a re-layout per pixel
Stable on a tablet rotationA resize() on the orientation changeSome in-app browsers miss the observer
A chart exported for a reportA higher ratio then restoreScreen detail is too low for print

FAQ

Why does my chart keep growing taller?
The canvas is a direct child of an element whose height depends on its content, so each resize adds a little more. Wrap the canvas in an element with position: relative and an explicit height, which is the only element Chart.js needs to be able to measure.
How do I export at print quality?
Raise devicePixelRatio, resize the chart to the pixel dimensions you want, capture with toBase64Image, then restore the settings. Remember to composite a white background if the chart has transparency.

Dynamic data, streaming and performance Accessibility and colour choices

Last refreshed 2026-09-18.