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);| Situation | Behaviour | What to do |
|---|---|---|
| Wrapper has no height | The chart grows on every resize | Give the wrapper an explicit height |
| Canvas is a flex child | Height collapses to the content | Size the wrapper, not the canvas |
| Chart inside a hidden tab | Initialised at 0 by 0 | Call resize() when shown |
| A sidebar animates open | The chart lags behind | Call resize() on transitionend |
| Very wide screen | Ticks crowd together | maxTicksLimit and autoSkip |
| Full-screen mobile | maintainAspectRatio: true forces a shape | Set 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');
});toBase64ImageandtoDataURLboth 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 animatedupdate()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
toDataURLthrow. If any image is drawn into the chart, load it withcrossOrigin = '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');
});| Goal | Setting | Why |
|---|---|---|
| Print at usable detail | devicePixelRatio: 3 plus a resize | The canvas is a raster |
| No page break inside a chart | break-inside: avoid | A split canvas is unreadable |
| Readable on a phone | Fewer ticks, shorter frame | Labels crowd before the chart does |
| Smooth resize | resizeDelay: 100 | Prevents a re-layout per pixel |
| Stable on a tablet rotation | A resize() on the orientation change | Some in-app browsers miss the observer |
| A chart exported for a report | A higher ratio then restore | Screen 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.Related
Dynamic data, streaming and performance Accessibility and colour choices
Last refreshed 2026-09-18.