chart.js
A charting library that draws into a single HTML canvas element instead of building SVG nodes. You hand it a config object with a type, a data object of labels and datasets, and an options object, and it renders line, bar, pie, doughnut, radar, polar area, scatter, or bubble charts with animations, tooltips, and a legend already wired up. Version 4 is built from registerable pieces: controllers, scales, elements, and plugins are separate exports, so you either import 'chart.js/auto' and get everything or register only the parts you use and ship less code.
Still the default pick for ordinary line, bar, and pie charts in a web app: cheap to learn, good defaults, and every framework has a wrapper. Reach for ECharts or ApexCharts once you need exotic chart types or interaction that Chart.js only gets through third-party plugins.
Use it if
- You need the standard business chart types (line, bar, pie, doughnut, radar, scatter, bubble, polar area) with sensible defaults, configured through a JSON-shaped object rather than drawing code
- You are plotting a few thousand points and want canvas rendering: SVG-based chart libraries start creating one DOM node per point and stutter well before canvas does
- You want a maintained framework wrapper instead of writing one: react-chartjs-2, vue-chartjs, and ng2-charts all follow the Chart.js majors
- You need charts on a page with no build step, since dist/chart.umd.js works from a plain script tag or a CDN
- You already have chartjs-plugin-datalabels, chartjs-plugin-zoom, or chartjs-plugin-annotation in mind, because that plugin ecosystem is the main reason to pick it over newer libraries
- You need vector output or DOM-level accessibility: the whole chart is one canvas element, so there is nothing for a screen reader to walk, nothing to select as text, and no SVG to hand a print pipeline
- You need a chart type outside the built-in set (treemap, sankey, candlestick, funnel, gantt, geo maps): every one of those is a third-party plugin maintained separately, and each has its own compatibility matrix against the Chart.js major
- Bundle weight matters: the full build is around 67 KB gzipped, and tree-shaking only pays off if you hand-register every controller, scale, and element yourself. uPlot draws time series in a small fraction of that
- You need fixes quickly: 4.5.1 shipped in October 2025, the repo's last push was late May 2026, and there are roughly 500 open issues, so a bug that blocks you may sit for months
- You want built-in interaction like brush zoom, crosshairs, or annotations: none of that is in core, and stacking three community plugins means three things to re-check on every upgrade
Setup reality
npm install chart.js is the whole install, no peer dependencies and types are bundled. The friction starts at the first import. Pick 'chart.js/auto' and you pull the entire library into your bundle; pick the named imports and you must call Chart.register() with every controller, scale, element, and plugin you touch, or you get a runtime error like "line is not a registered controller". A time axis needs a separate adapter package (chartjs-adapter-date-fns plus date-fns) or it throws about a missing date adapter. Sizing bites next: never set width and height on the canvas, wrap it in a positioned parent with a real height, and set maintainAspectRatio to false. In React, StrictMode's double effect will hit "Canvas is already in use" unless you destroy the chart in the cleanup function. If you are porting v2 config, most option keys moved: tooltip and legend now live under options.plugins, and scales are an object keyed by axis id instead of xAxes and yAxes arrays.
Patterns
Render a chart with the auto bundleminimal-chart
import Chart from 'chart.js/auto';
const chart = new Chart(document.getElementById('sales'), {
type: 'bar',
data: {
labels: ['Jan', 'Feb', 'Mar'],
datasets: [{ label: 'Units', data: [12, 19, 7] }],
},
options: {
scales: { y: { beginAtZero: true } },
},
});chart.js/auto registers every controller, scale, and plugin for you, so nothing is tree-shaken; fine for a prototype or a script tag, expensive in a shipped bundle.
Register only the pieces you usetree-shaken-setup
import {
Chart,
LineController,
LineElement,
PointElement,
LinearScale,
CategoryScale,
Filler,
Tooltip,
Legend,
} from 'chart.js';
Chart.register(
LineController,
LineElement,
PointElement,
LinearScale,
CategoryScale,
Filler,
Tooltip,
Legend,
);
const chart = new Chart(canvas, {
type: 'line',
data: { labels, datasets: [{ label: 'Load', data, fill: true }] },
});Forget one import and you get a runtime error naming the missing piece, for example "line is not a registered controller". Filler is its own plugin, so fill: true does nothing until it is registered.
Push new data into a live chartupdate-data
chart.data.labels.push('Apr');
chart.data.datasets[0].data.push(23);
chart.update();
// swap a whole series and skip the animation
chart.data.datasets[0].data = [5, 6, 7, 8];
chart.update('none');Mutate chart.data in place and then call update(); nothing redraws on assignment alone. Passing 'none' skips animation, which matters for polling or streaming updates.
Destroy a chart before reusing the canvasdestroy-before-reuse
let chart = null;
function render(canvas, data) {
if (chart) chart.destroy();
chart = new Chart(canvas, { type: 'doughnut', data });
return chart;
}
// React
useEffect(() => {
const c = new Chart(ref.current, config);
return () => c.destroy();
}, []);Creating a second chart on the same canvas throws "Canvas is already in use". React StrictMode runs effects twice in development, so the cleanup destroy() is required, not optional.
Make the chart fill a sized containerresponsive-sizing
<!-- parent must have a real height -->
<div style="position: relative; height: 320px; width: 100%">
<canvas id="c"></canvas>
</div>
<script type="module">
new Chart(document.getElementById('c'), {
type: 'line',
data,
options: { responsive: true, maintainAspectRatio: false },
});
</script>Do not set width or height attributes on the canvas; Chart.js manages them. With maintainAspectRatio false and an auto-height parent, the chart grows on every resize event.
Plot a time series on a date axistime-axis
// npm i chart.js chartjs-adapter-date-fns date-fns
import 'chartjs-adapter-date-fns';
import { Chart, LineController, LineElement, PointElement, LinearScale, TimeScale, Tooltip } from 'chart.js';
Chart.register(LineController, LineElement, PointElement, LinearScale, TimeScale, Tooltip);
new Chart(canvas, {
type: 'line',
data: {
datasets: [{
label: 'Signups',
data: [
{ x: '2026-07-01', y: 12 },
{ x: '2026-07-02', y: 19 },
],
}],
},
options: {
scales: {
x: { type: 'time', time: { unit: 'day', tooltipFormat: 'PP' } },
},
},
});Chart.js bundles no date library. Without an adapter package the time scale throws about a missing date adapter implementation; the date-fns, Luxon, Moment, and Day.js adapters are separate npm packages.
Format tooltip labels and axis ticksformat-tooltip
const inr = new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR', maximumFractionDigits: 0 });
options: {
plugins: {
tooltip: {
callbacks: {
label: (ctx) => `${ctx.dataset.label}: ${inr.format(ctx.parsed.y)}`,
},
},
legend: { position: 'bottom' },
},
scales: {
y: { ticks: { callback: (value) => `${value / 1000}k` } },
},
}Since v3 tooltip and legend live under options.plugins, not at the top level like v2. ctx.parsed holds the value after scale parsing, while ctx.raw is whatever you put in the data array.
Mix a line onto a bar chart with its own axissecond-y-axis
new Chart(canvas, {
type: 'bar',
data: {
labels,
datasets: [
{ label: 'Revenue', data: revenue, yAxisID: 'y' },
{ type: 'line', label: 'Margin %', data: margin, yAxisID: 'y1' },
],
},
options: {
scales: {
y: { position: 'left', beginAtZero: true },
y1: {
position: 'right',
min: 0,
max: 100,
grid: { drawOnChartArea: false },
},
},
},
});The top-level type is only the default for datasets that do not set their own. With named imports you must register BarController and LineController, or the mixed dataset throws a not-registered error. drawOnChartArea false stops two grids from overlapping.
Stack bars and split them into groupsstacked-bar
options: {
scales: {
x: { stacked: true },
y: { stacked: true, beginAtZero: true },
},
}
// datasets can opt into separate stacks
datasets: [
{ label: 'Web', data: web, stack: 'traffic' },
{ label: 'App', data: app, stack: 'traffic' },
{ label: 'Target', data: target, stack: 'goal' },
]Both axes need stacked: true; setting only y leaves the bars side by side. The dataset-level stack key is what separates groups, and datasets sharing a name stack together.
Downsample a large series before drawinglarge-dataset-decimation
import { Chart, LineController, LineElement, PointElement, LinearScale, Decimation } from 'chart.js';
Chart.register(LineController, LineElement, PointElement, LinearScale, Decimation);
new Chart(canvas, {
type: 'line',
data: { datasets: [{ data: points, borderWidth: 1, pointRadius: 0 }] },
options: {
parsing: false,
normalized: true,
animation: false,
plugins: {
decimation: { enabled: true, algorithm: 'lttb', samples: 500 },
},
scales: { x: { type: 'linear' } },
},
});Decimation is skipped unless parsing is false and the data is already an array of {x, y} sorted ascending on a linear or time index axis. pointRadius 0 and animation false do more for frame rate than the plugin on mid-size sets.
Write an inline plugin for a solid backgroundcustom-plugin-background
const solidBackground = {
id: 'solidBackground',
beforeDraw(chart, args, opts) {
const { ctx } = chart;
ctx.save();
ctx.globalCompositeOperation = 'destination-over';
ctx.fillStyle = opts.color || '#ffffff';
ctx.fillRect(0, 0, chart.width, chart.height);
ctx.restore();
},
};
const chart = new Chart(canvas, {
type: 'bar',
data,
options: { plugins: { solidBackground: { color: '#ffffff' } } },
plugins: [solidBackground],
});
const png = chart.toBase64Image();Plugins passed in the config plugins array apply to that chart only; Chart.register() makes them global. Without one, toBase64Image() returns a transparent PNG that turns black in some email clients and PDF viewers.
Handle a click on a bar or pointclick-datapoint
options: {
onClick(event, elements, chart) {
if (!elements.length) return;
const { datasetIndex, index } = elements[0];
const label = chart.data.labels[index];
const value = chart.data.datasets[datasetIndex].data[index];
console.log('clicked', label, value);
},
}
// outside the config, from a raw DOM event
const hits = chart.getElementsAtEventForMode(
domEvent,
'nearest',
{ intersect: true },
true,
);elements is empty whenever the click misses a drawn element, including clicks in chart whitespace, so guard before indexing. Set intersect to false if you want the nearest point rather than a direct hit.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| echarts | npm | You need chart types Chart.js does not have (sankey, treemap, candlestick, geo) in one maintained package |
| apexcharts | npm | You want SVG output, built-in toolbar, zoom, and CSV export without assembling plugins |
| uplot | npm | Time series with tens of thousands of points where a tiny bundle and draw time beat feature count |