Among many charting libraries for JavaScript application developers, Chart.js is currently the most popular one according to GitHub stars (~60,000) and npm downloads (~9,000,000 monthly).

D3.js and three.js also belong to the data visualization category and have more stars on GitHub, however, it’s unlikely that you’d pick them for charting: the former provides only low-level primitives like lines and scales and the latter is a 3D library. Apache ECharts is comparable by stars on GitHub but way less popular by downloads. Tools like Recharts, Plotly, Frappe, Chartist, Highcharts, nivo, and many others are also fine choices but beware of their framework support (universal or React only), rendering options (canvas and/or SVG), and licenses (open-source or proprietary).

In this guide, we’ll focus on Chart.js because it’s a popular, open-source, feature-rich, and well-supported library. Moreover, Chart.js v4 release has just landed, bringing minimal breaking changes alongside impactful features and bug fixes. We’ll walk through Chart.js features and build an example app like this:

demo

You can check the live demo and explore full source code on GitHub. Eager to start building? Jump directly to the relevant section.

Why Chart.js?

Chart.js was created and announced in 2013 but has come a long way since then. It’s open-source, licensed under the very permissive MIT license, and maintained by an active community.

Features. Chart.js provides a set of frequently used chart types, plugins, and customization options. In addition to a reasonable set of built-in chart types, you can use additional community-maintained chart types. On top of that, it’s possible to combine several chart types into a mixed chart (essentially, blending multiple chart types into one on the same canvas). There’s also a plugin directory where you can find add-ons for annotations, zoom, or drag-and-drop.

Defaults. Chart.js comes with a sound default configuration, making it very easy to start with and get an app that is ready for production. Chances are you will get a very appealing chart even if you don’t specify any options at all. For instance, Chart.js has animations toggled on by default, so you can instantly bring attention to the story you’re telling with data.

Integrations. Chart.js comes with built-in TypeScript typings and is compatible with all popular JavaScript frameworks including React, Vue, Svelte, and Angular. You can use Chart.js directly or leverage well-maintained wrapper packages that allow for more native integration with your frameworks of choice.

Developer experience. Chart.js has very thorough documentationAPI reference, and examples. Maintainers and community members eagerly engage in conversations on Slack, GitHub Discussions, and Stack Overflow where more than 11,000 questions are tagged with chart.js.

Canvas rendering. Chart.js renders chart elements on HTML5 canvas unlike several other, mostly D3.js-based, charting libraries that render as SVG. Canvas rendering makes Chart.js very performant, especially for large datasets and complex visualizations that would otherwise require thousands of SVG nodes in the DOM tree. At the same time, canvas rendering disallows CSS styling, so you have to use built-in options for that.

Performance. Chart.js is very well suited for large datasets. Such datasets can be efficiently ingested using the internal format so you can skip data parsing and normalization. Alternatively, data decimation can be configured to sample the dataset and reduce its size before rendering. In the end, Chart.js uses canvas for efficient rendering, reducing the toll on your DOM tree in comparison to SVG rendering. Also, tree-shaking support allows you to include minimal parts of Chart.js code into your bundle, reducing bundle size and page load time.

Community. Chart.js is actively developed and maintained by the community. With minor releases on an approximately bi-monthly basis and major releases with breaking changes every couple of years, Chart.js keeps the balance between adding new features and making it a hassle to keep up with them.

What’s new in Chart.js v4?

The v4 release contains limited breaking changes and primarily focuses on bug fixes as well as quality of life and developer experience improvements. Here are a few highlights:

  • Refactored border and ticks configuration.
  • Support for ES modules and isomorphic builds, unlocking out-of-the-box compatibility with Vite, SvelteKit, and server-side usage in general.
  • Support for tree-shaking, allowing for removal of unneeded parts of Chart.js code from the JavaScript application bundle.
  • Infrastructure for the ongoing migration of the Chart.js code base to TypeScript.

Please follow the Chart.js 4.x migration guide if you need one.

Getting started with Chart.js v4

Let’s build a Chart.js data visualization with a couple of charts from scratch. In a new folder, create the package.json file with the following contents:

{
"name": "chartjs-example",
"version": "1.0.0",
"license": "MIT",
"scripts": {
"dev": "parcel src/index.html",
"build": "parcel build src/index.html"
},
"devDependencies": {
"parcel": "^2.6.2"
},
"dependencies": {
"@cubejs-client/core": "^0.30.29",
"chart.js": "^4.0.0"
}
}

Modern front-end applications often use JavaScript module bundlers, so we’ve picked Parcel as a nice zero-configuration build tool. We’re also installing Chart.js v4 and a JavaScript client for Cube, an open-source API for data apps we’ll use to fetch real-world data (more on that later).

Run npm install, yarn install, or pnpm install to install the dependencies, then create the src folder. Inside that folder, we’ll need a very simple index.html file:

<!doctype html>
<html lang="en">
<head>
<title>Chart.js example</title>
</head>
<body>
<!-- <div style="width: 500px;"><canvas id="dimensions"></canvas></div><br/> -->
<div style="width: 800px;"><canvas id="acquisitions"></canvas></div>
<!-- <script type="module" src="dimensions.js"></script> -->
<script type="module" src="acquisitions.js"></script>
</body>
</html>

As you can see, Chart.js requires minimal markup: a canvas tag with an id by which we’ll reference the chart later. By default, Chart.js charts are responsive and take the whole enclosing container. So, we set the width of the div to control chart width.

Lastly, let’s create the src/acquisitions.js file with the following contents:

import { Chart } from 'chart.js/auto'
(async function() {
const data = [
{ year: 2010, count: 10 },
{ year: 2011, count: 20 },
{ year: 2012, count: 15 },
{ year: 2013, count: 25 },
{ year: 2014, count: 22 },
{ year: 2015, count: 30 },
{ year: 2016, count: 28 },
];
new Chart(
document.getElementById('acquisitions'),
{
type: 'bar',
data: {
labels: data.map(row => row.year),
datasets: [
{
label: 'Acquisitions by year',
data: data.map(row => row.count)
}
]
}
}
);
})();

Let’s walk through this code:

  • We import Chart, the main Chart.js class, from the special chart.js/auto path. It loads all available Chart.js components (which is very convenient) but disallows tree-shaking. We’ll address that later.
  • We instantiate a new Chart instance and provide two arguments: the canvas element where the chart would be rendered and the options object.
  • We just need to provide a chart type (bar) and provide data which consists of labels (often, numeric or textual descriptions of data points) and an array of datasets (Chart.js supports multiple datasets for most chart types). Each dataset is designated with a label and contains an array of data points.
  • For now, we only have a few entries of dummy data. So, we extract year and count properties to produce the arrays of labels and data points within the only dataset.

Time to run the example with npm run dev, yarn dev, or pnpm dev and navigate to localhost:1234 in your web browser:

demo-1

With just a few lines of code, we’ve got a chart with a lot of features: a legend, grid lines, ticks, and tooltips shown on hover. Refresh the web page a few times to see that the chart is also animated. Try clicking on the “Acquisitions by year” label to see that you’re also able to toggle datasets visibility (especially useful when you have multiple datasets).

Simple customizations

Let’s see how Chart.js charts can be customized. First, let’s turn off the animations so the chart appears instantly. Second, let’s hide the legend and tooltips since we have only one dataset and pretty trivial data.

Replace the new Chart(...); invocation in src/acquisitions.js with the following snippet:

new Chart(
document.getElementById('acquisitions'),
{
type: 'bar',
options: {
animation: false,
plugins: {
legend: {
display: false
},
tooltip: {
enabled: false
}
}
},
data: {
labels: data.map(row => row.year),
datasets: [
{
label: 'Acquisitions by year',
data: data.map(row => row.count)
}
]
}
}
);

As you can see, we’ve added the options property to the second argument—that’s how you can specify all kinds of customization options for Chart.js. The animation is disabled with a boolean flag provided via animation. Most chart-wide options (e.g., responsiveness or device pixel ratio) are configured like this.

The legend and tooltips are hidden with boolean flags provided under the respective sections in plugins. Note that some of Chart.js features are extracted into plugins: self-contained, separate pieces of code. A few of them are available as a part of Chart.js distribution, other plugins are maintained independently and can be located in the awesome list of plugins, framework integrations, and additional chart types.

You should be able to see the updated minimalistic chart in your browser.

Real-world data with Cube

With hardcoded, limited-size, unrealistic data, it’s hard to show the full potential of Chart.js. Let’s quickly connect to a data API to make our example application closer to a production use case.

Let’s create the src/api.js file with the following contents:

import { CubejsApi } from '@cubejs-client/core';
const apiUrl = 'https://heavy-lansford.gcp-us-central1.cubecloudapp.dev/cubejs-api/v1';
const cubeToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjEwMDAwMDAwMDAsImV4cCI6NTAwMDAwMDAwMH0.OHZOpOBVKr-sCwn8sbZ5UFsqI3uCs6e4omT7P6WVMFw';
const cubeApi = new CubejsApi(cubeToken, { apiUrl });
export async function getAquisitionsByYear() {
const acquisitionsByYearQuery = {
dimensions: [
'Artworks.yearAcquired',
],
measures: [
'Artworks.count'
],
filters: [ {
member: 'Artworks.yearAcquired',
operator: 'set'
} ],
order: {
'Artworks.yearAcquired': 'asc'
}
};
const resultSet = await cubeApi.load(acquisitionsByYearQuery);
return resultSet.tablePivot().map(row => ({
year: parseInt(row['Artworks.yearAcquired']),
count: parseInt(row['Artworks.count'])
}));
}
export async function getDimensions() {
const dimensionsQuery = {
dimensions: [
'Artworks.widthCm',
'Artworks.heightCm'
],
measures: [
'Artworks.count'
],
filters: [
{
member: 'Artworks.classification',
operator: 'equals',
values: [ 'Painting' ]
},
{
member: 'Artworks.widthCm',
operator: 'set'
},
{
member: 'Artworks.widthCm',
operator: 'lt',
values: [ '500' ]
},
{
member: 'Artworks.heightCm',
operator: 'set'
},
{
member: 'Artworks.heightCm',
operator: 'lt',
values: [ '500' ]
}
]
};
const resultSet = await cubeApi.load(dimensionsQuery);
return resultSet.tablePivot().map(row => ({
width: parseInt(row['Artworks.widthCm']),
height: parseInt(row['Artworks.heightCm']),
count: parseInt(row['Artworks.count'])
}));
}

Let’s see what’s happening there:

  • We import the JavaScript client library for Cube, an open-source API for data apps, configure it with the API URL (apiUrl) and the authentication token (cubeToken), and finally instantiate the client (cubeApi).
  • Cube API is hosted in Cube Cloud and connected to a database with a public dataset of ~140,000 records representing all of the artworks in the collection of the Museum of Modern Art in New York, USA. Certainly, a more real-world dataset than what we’ve got now.
  • We define a couple of asynchronous functions to fetch data from the API: getAquisitionsByYear and getDimensions. The first one returns the number of artworks by the year of acquisition, the other returns the number of artworks for every width-height pair (we’ll need it for another chart).
  • Let’s take a look at getAquisitionsByYear. First, we create a declarative, JSON-based query in the acquisitionsByYearQuery variable. As you can see, we specify that for every yearAcquired we’d like to get the count of artworks; yearAcquired has to be set (i.e., not undefined); the result set would be sorted by yearAcquired in the ascending order.
  • Second, we fetch the resultSet by calling cubeApi.load and map it to an array of objects with desired year and count properties.

Now, let’s deliver the real-world data to our chart. Please apply a couple of changes to src/acquisitions.js: add an import and replace the definition of the data variable.

import { getAquisitionsByYear } from './api'
// ...
const data = await getAquisitionsByYear();

Done! Now, our chart with real-world data looks like this. Looks like something interesting happened in 1964, 1968, and 2008!

demo-2

We’re done with the bar chart. Let’s try another Chart.js chart type.

Further customizations

Chart.js supports many common chart types.

For instance, Bubble chart allows to display three dimensions of data at the same time: locations on x and y axes represent two dimensions, and the third dimension is represented by the size of the individual bubbles.

To create the chart, stop the already running application, then go to src/index.html, and uncomment the following two lines:

<div style="width: 500px;"><canvas id="dimensions"></canvas></div><br/>
<script type="module" src="dimensions.js"></script>

Then, create the src/dimensions.js file with the following contents:

import { Chart } from 'chart.js/auto'
import { getDimensions } from './api'
(async function() {
const data = await getDimensions();
new Chart(
document.getElementById('dimensions'),
{
type: 'bubble',
data: {
labels: data.map(x => x.year),
datasets: [
{
label: 'Dimensions',
data: data.map(row => ({
x: row.width,
y: row.height,
r: row.count
}))
}
]
}
}
);
})();

Probably, everything is pretty straightforward there: we get data from the API and render a new chart with the bubble type, passing three dimensions of data as x, y, and r (radius) properties.

Now, start the application again with npm run dev, yarn dev, or pnpm dev. We can review the new chart now:

demo-3

Well, it doesn’t look pretty.

First of all, the chart is not square. Artworks’ width and height are equally important so we’d like to make the chart width equal to its height as well. By default, Chart.js charts have the aspect ratio of either 1 (for all radial charts, e.g., a doughnut chart) or 2 (for all the rest). Let’s modify the aspect ratio for our chart:

// ...
new Chart(
document.getElementById('dimensions'),
{
type: 'bubble',
options: {
aspectRatio: 1,
},
// ...

Looks much better now:

demo-4

However, it’s still not ideal. The horizontal axis spans from 0 to 500 while the vertical axis spans from 0 to 450. By default, Chart.js automatically adjusts the range (minimum and maximum values) of the axes to the values provided in the dataset, so the chart “fits” your data. Apparently, MoMa collection doesn’t have artworks in the range of 450 to 500 cm in height. Let’s modify the axes configuration for our chart to account for that:

// ...
new Chart(
document.getElementById('dimensions'),
{
type: 'bubble',
options: {
aspectRatio: 1,
scales: {
x: {
max: 500
},
y: {
max: 500
}
}
},
// ...

Great! Behold the updated chart:

demo-5

However, there’s one more nitpick: what are these numbers? It’s not very obvious that the units are centimetres. Let’s apply a custom tick format to both axes to make things clear. We’ll provide a callback function that would be called to format each tick value. Here’s the updated axes configuration:

// ...
new Chart(
document.getElementById('dimensions'),
{
type: 'bubble',
options: {
aspectRatio: 1,
scales: {
x: {
max: 500,
ticks: {
callback: value => `${value / 100} m`
}
},
y: {
max: 500,
ticks: {
callback: value => `${value / 100} m`
}
}
}
},
// ...

Perfect, now we have proper units on both axes:

demo-6

Multiple datasets

Chart.js plots each dataset independently and allows to apply custom styles to them.

Take a look at the chart: there’s a visible “line” of bubbles with equal x and y coordinates representing square artworks. It would be cool to put these bubbles in their own dataset and paint them differently. Also, we can separate “taller” artworks from “wider” ones and paint them differently, too.

Here’s how we can do that. Replace the datasets with the following code:

// ...
datasets: [
{
label: 'width = height',
data: data
.filter(row => row.width === row.height)
.map(row => ({
x: row.width,
y: row.height,
r: row.count
}))
},
{
label: 'width > height',
data: data
.filter(row => row.width > row.height)
.map(row => ({
x: row.width,
y: row.height,
r: row.count
}))
},
{
label: 'width < height',
data: data
.filter(row => row.width < row.height)
.map(row => ({
x: row.width,
y: row.height,
r: row.count
}))
}
]
// ..

As you can see, we define three datasets with different labels. Each dataset gets its own slice of data extracted with filter. Now they are visually distinct and, as you already know, you can toggle their visibility independently.

demo-7

Here we rely on the default color palette. However, keep in mind every chart type supports a lot of dataset options that you can feel free to customize.

Plugins

Another—and very powerful!—way to customize Chart.js charts is to use plugins. You can find some in the plugin directory or create your own, ad-hoc ones. In Chart.js ecosystem, it’s idiomatic and expected to fine tune charts with plugins. For example, you can customize canvas background or add a border to it with simple ad-hoc plugins. Let’s try the latter.

Plugins have an extensive API but, in a nutshell, a plugin is defined as an object with a name and one or more callback functions defined in the extension points. Insert the following snippet before and in place of the new Chart(...); invocation in src/dimensions.js:

// ...
const chartAreaBorder = {
id: 'chartAreaBorder',
beforeDraw(chart, args, options) {
const { ctx, chartArea: { left, top, width, height } } = chart;
ctx.save();
ctx.strokeStyle = options.borderColor;
ctx.lineWidth = options.borderWidth;
ctx.setLineDash(options.borderDash || []);
ctx.lineDashOffset = options.borderDashOffset;
ctx.strokeRect(left, top, width, height);
ctx.restore();
}
};
new Chart(
document.getElementById('dimensions'),
{
type: 'bubble',
plugins: [ chartAreaBorder ],
options: {
plugins: {
chartAreaBorder: {
borderColor: 'red',
borderWidth: 2,
borderDash: [ 5, 5 ],
borderDashOffset: 2,
}
},
aspectRatio: 1,
// ...

As you can see, in this chartAreaBorder plugin, we acquire the canvas context, save its current state, apply styles, draw a rectangular shape around the chart area, and restore the canvas state. We’re also passing the plugin in plugins so it’s only applied to this particular chart. We also pass the plugin options in options.plugins.chartAreaBorder; we could surely hardcode them in the plugin source code but it’s much more reusable this way.

Our bubble chart looks fancier now:

demo-8

Tree-shaking

In production, we strive to ship as little code as possible, so the end users can load our data applications faster and have better experience. For that, we’ll need to apply tree-shaking which is fancy term for removing unused code from the JavaScript bundle.

Chart.js fully supports tree-shaking with its component design. You can register all Chart.js components at once (which is convenient when you’re prototyping) and get them bundled with your application. Or, you can register only necessary components and get a minimal bundle, much less in size.

Let’s inspect our example application. What’s the bundle size? You can stop the yarn dev command and run yarn build. In a few moments, you’ll get something like this:

% yarn build
yarn run v1.22.17
$ parcel build src/index.html
✨ Built in 88ms
dist/index.html 381 B 164ms
dist/index.74a47636.js 265.48 KB 1.25s
dist/index.ba0c2e17.js 881 B 63ms
✨ Done in 0.51s.

We can see that Chart.js and other dependencies were bundled together in a single 265 KB file.

To reduce the bundle size, we’ll need to apply a couple of changes to src/acquisitions.js and src/dimensions.js. First, we’ll need to remove the following import statement from both files: import { Chart } from 'chart.js/auto'.

Instead, let’s load only necessary components and “register” them with Chart.js using Chart.register(...). Here’s what we need in src/acquisitions.js:

import {
Chart,
Colors,
BarController,
CategoryScale,
LinearScale,
BarElement,
Legend
} from 'chart.js'
Chart.register(
Colors,
BarController,
BarElement,
CategoryScale,
LinearScale,
Legend
);

And here’s the snippet for src/dimensions.js:

import {
Chart,
Colors,
BubbleController,
CategoryScale,
LinearScale,
PointElement,
Legend
} from 'chart.js'
Chart.register(
Colors,
BubbleController,
PointElement,
CategoryScale,
LinearScale,
Legend
);

You can see that, in addition to the Chart class, we’re also loading a controller for the chart type, scales, and other chart elements (e.g., bars or points). You can look all available components up in the documentation.

Alternatively, you can follow Chart.js advice in the console. For example, if you forget to import BarController for your bar chart, you’ll see the following message in the browser console:

Unhandled Promise Rejection: Error: "bar" is not a registered controller.

Remember to carefully check for imports from chart.js/auto when preparing your application for production. It takes only one import like this to effectively disable tree-shaking.

Now, let’s inspect our application once again. Run yarn build and you’ll get something like this:

% yarn build
yarn run v1.22.17
$ parcel build src/index.html
✨ Built in 88ms
dist/index.html 381 B 176ms
dist/index.5888047.js 208.66 KB 1.23s
dist/index.dcb2e865.js 932 B 58ms
✨ Done in 0.51s.

By importing and registering only select components, we’ve removed more than 56 KB of unnecessary code. Given that other dependencies take ~50 KB in the bundle, tree-shaking helps remove ~25% of Chart.js code from the bundle for our example application.

Wrapping up

Now you’re familiar with all major concepts of Chart.js: chart types and elements, datasets, customization, plugins, components, and tree-shaking.

Feel free to review many examples of charts in the documentation and check the awesome list of Chart.js plugins and additional chart types as well as framework integrations (e.g., React, Vue, Svelte, etc.).

Also, if you need a fast and convenient API for your data, consider using Cube with Chart.js. Check the docs and create a free Cube Cloud account today.

Have fun and good luck building with Chart.js and Cube!