Chartist keeps its core small and leaves the extras to you. Tooltips, value labels, legends, a bar that turns red past a threshold: none of them are options. Instead, every step of drawing a chart is an event, and a listener can change what that step produces. A plugin is a set of such listeners with a name, ready to share.
The pipeline
A chart goes through seven phases, in this order. Each one emits an event with what it produced:
| Phase | Payload | What it is |
|---|---|---|
resolve |
{ config } |
The config with defaults applied: padding, coordinates, layers, axes |
prepare |
{ data } |
The data, each series as { values, name, className, meta } |
layout |
{ width, height, padding, area } |
The chart’s size and the drawing area inside the padding and axes |
compose |
a scene node | Each element the layers draw: series, lines, points, bars, slices, grid lines and labels |
scene |
{ scene } |
The whole tree, its root the svg |
render |
{ scene, output } |
The renderer has written the SVG |
mount |
{ scene, output, elementOf } |
The SVG is in the page; elementOf(node) gives you a node’s element |
A listener that returns a new payload changes the phase, and later phases build on the change. Return nothing to leave it as it is. The return values of render and mount listeners are ignored: by then, the SVG exists.
When the container resizes, the chart runs again from layout. The config and data haven’t changed, so resolve and prepare don’t run, and their listeners aren’t called.
Listening
Pass listeners in the config of chart(), by phase name:
import { autoScaleAxis, cartesianAxes, chart, line, stepAxis } from 'chartist';
chart('#chart', {
data: { labels: ['Mon', 'Tue', 'Wed'], series: [[1, 3, 2]] },
axes: cartesianAxes([
{ id: 'x', side: 'bottom', type: stepAxis() },
{ id: 'y', side: 'left', type: autoScaleAxis() }
]),
layers: [line()],
on: {
layout: ({ width, height }) =>
console.log(`laid out at ${width}×${height}`),
mount: ({ scene }) => console.log('mounted', scene)
}
});
The presets take the same listeners:
import { lineChart } from 'chartist';
lineChart(
'#chart',
{ series: [[1, 3, 2]] },
{ on: { mount: () => console.log('drawn') } }
);
Or call on() and off() on the chart handle. Both return the handle, so they chain. off(name, listener) removes that listener. off(name) without one removes the listeners you added to that event, with on() or in the on config, and leaves the ones of plugins alone. The name '*' listens to all events, with the event name as the first argument:
import { lineChart } from 'chartist';
const chart = lineChart('#chart', { series: [[1, 3, 2]] });
const log = (name: string, payload: unknown) => console.log(name, payload);
chart.on('*', log);
chart.update({}); // logs every phase of this render
chart.off('*', log);
Scene nodes
The compose event is where most plugins work. Its payload is a scene node: a plain description of an SVG element with its name, attrs, classes and children, plus what Chartist knows about it:
role:series,line,point,area,bar,slice,grid,gridBackgroundorlabelctx: its data, such asseriesIndex,valueIndex,valueandmetakey: a stable id like0/1/3(layer, series, value)
In TypeScript, checking the role narrows the node: after node.role === 'point', node.ctx.x is a number and node.ctx.path is an error, since only lines, areas and slices have a path. findAll(scene, { role: 'bar' }) returns bar nodes, and the builder methods keep the type. The live examples’ editor shows it too: type node.ctx. inside a role check, and it lists that role’s fields. If your plugin adds nodes with a role of its own, declare it in the NodeRoles interface, the same way as custom events.
Every node with a role passes through the listeners, children before their parents. So a series listener sees its points and lines as the other listeners left them.
Nodes are immutable. Their methods (attr(), addClass(), removeClass(), append(), remove(), replace(), text(), empty()) return a new node, and you return that node from the listener.
Dynamic coloring
Color isn’t only for stylesheets. Here a plugin reads each bar’s value and gives it a hue from red to green:
import 'chartist/styles.css';
import { barChart, type ChartPlugin } from 'chartist';
const values = [3, 6, 9, 12, 9, 6, 3, 6, 9, 12, 9, 6];
const min = Math.min(...values);
const max = Math.max(...values);
// Colors each bar by its value: red for low, green for high.
const heat: ChartPlugin = {
name: 'heat',
install(chart) {
chart.on('compose', node => {
if (node.role !== 'bar') {
return;
}
// Bar values are { x, y } (or a hole).
const { value } = node.ctx;
const y =
value && typeof value === 'object' && 'y' in value ? value.y : min;
const share = (y - min) / (max - min);
const hue = Math.round(share * 120);
return node.attr({ style: `stroke: hsl(${hue} 70% 45%)` });
});
}
};
barChart(
'#chart',
{ series: [values] },
{ axisX: { showLabel: false }, plugins: [heat] }
);Change, replace or drop
To change a node, return it with new attributes or classes, as above. To replace it, return a different node; keep its role, ctx and key so later listeners still know what it is. To drop it, return null:
import { node, type ChartPlugin } from 'chartist';
/** Circles instead of points, and no points for zero values. */
export const circles: ChartPlugin = {
name: 'circles',
install(chart) {
chart.on('compose', current => {
if (current.role !== 'point') {
return;
}
const { x, y, value } = current.ctx;
// Line values are { x, y } (or a hole).
if (value && typeof value === 'object' && 'y' in value && value.y === 0) {
return null;
}
return node('circle', { cx: x, cy: y, r: 5 }, 'ct-circle', {
role: current.role,
ctx: current.ctx,
key: current.key
});
});
}
};
The circle has none of the point’s classes, so style it yourself, for example with .ct-circle { fill: var(--ct-color); }.
Decorate
To add elements, append them to a parent. A value above each bar goes into the bar’s series, which comes through the listener after its bars:
import { node, type ChartPlugin } from 'chartist';
export const valueLabels: ChartPlugin = {
name: 'value-labels',
install(chart) {
chart.on('compose', series => {
if (series.role !== 'series') {
return;
}
return series.children
.filter(child => child.role === 'bar')
.reduce((result, bar) => {
const { x2, y2 } = bar.attrs;
const { value: v } = bar.ctx;
const value =
v && typeof v === 'object' && 'y' in v ? v.y : undefined;
return result.append(
node(
'text',
{ x: x2, y: y2 - 8, 'text-anchor': 'middle' },
'ct-value'
).text(String(value))
);
}, series);
});
}
};
Writing a plugin
A plugin is an object with a name and an install function. install runs once, before the first render, with the chart handle, and registers listeners on it. They stay for every later render, after updates and resizes too. For options, write a function that returns the plugin:
import { barChart, type ChartPlugin } from 'chartist';
/** Bars at or above the limit get the class ct-over. */
const threshold = (limit: number): ChartPlugin => ({
name: 'threshold',
install(chart) {
chart.on('compose', current => {
if (current.role !== 'bar') {
return;
}
// A number, { x, y } (with an X scale axis) or a hole.
const { value } = current.ctx;
const y =
typeof value === 'number'
? value
: value && 'y' in value
? value.y
: undefined;
return y !== undefined && y >= limit
? current.addClass('ct-over')
: undefined;
});
}
});
barChart(
'#chart',
{ labels: ['Q1', 'Q2', 'Q3', 'Q4'], series: [[5, 4, 3, 7]] },
{ plugins: [threshold(5)] }
);
Plugins go into plugins, in the config of chart() or the options of the presets. Plugins install in order, so with two listeners on the same event, the earlier plugin’s runs first. chart() also takes a [plugin, options] pair as an entry, and install receives the options as its second argument.
Custom events
Plugins can emit events of their own with chart.emit(name, payload). Declare each event and its payload in the ChartEvents interface (TypeScript merges your declaration into Chartist’s), list it in provides, and listen to it with on() like to any other event. A misspelled name or a wrong payload is then a type error, not a silent no-op. This plugin reports clicks on bars; the page listens and shows the clicked value. Press Edit and click a bar:
import 'chartist/styles.css';
import './style.css';
import { barChart, findAll, type ChartPlugin } from 'chartist';
// The event and its payload, declared so `emit` and `on` are typed.
declare module 'chartist' {
interface ChartEvents {
'bar:select': { series: number; index: number; value?: number };
}
}
// Emits "bar:select" when someone clicks a bar.
const selectBars: ChartPlugin = {
name: 'select-bars',
provides: ['bar:select'],
install(chart) {
chart.on('mount', ({ scene, elementOf }) => {
for (const bar of findAll(scene, { role: 'bar' })) {
elementOf(bar)?.addEventListener('click', () =>
chart.emit('bar:select', {
series: bar.ctx.seriesIndex,
index: bar.ctx.valueIndex,
// Bar values are { x, y } (or a hole).
value:
bar.ctx.value &&
typeof bar.ctx.value === 'object' &&
'y' in bar.ctx.value
? bar.ctx.value.y
: undefined
})
);
}
});
}
};
const labels = ['Q1', 'Q2', 'Q3', 'Q4'];
const chart = barChart(
'#chart',
{
labels,
series: [
[5, 4, 3, 7],
[3, 2, 9, 5]
]
},
{ plugins: [selectBars] }
);
const output = document.querySelector('#selected');
chart.on('bar:select', ({ series, index, value }) => {
if (output) {
output.textContent = `Series ${series + 1}, ${labels[index]}: ${value}`;
}
});The click handlers go on in mount, the first phase in which the elements exist. There’s no cleanup to do: every render draws a new SVG, and the old elements go away with their handlers.
Phase names are reserved: emitting compose or mount throws. A prefix such as bar: keeps your names apart from other plugins’.
Sharing a plugin
A plugin is plain data and functions, so sharing one is publishing a module. Export a function that takes the options and returns the plugin, and type it with ChartPlugin:
import type { ChartPlugin } from 'chartist';
export interface HighlightOptions {
className?: string;
}
export function highlightLast(options: HighlightOptions = {}): ChartPlugin {
const className = options.className ?? 'ct-last';
return {
name: 'highlight-last',
install(chart) {
chart.on('compose', current => {
if (current.role !== 'series') {
return;
}
const last = current.children.at(-1);
return last && current.replace(last, last.addClass(className));
});
}
};
}
A few things keep a plugin working everywhere:
- Change the scene in
composeandscene, not the DOM. Then it also works when the chart is rendered to a string, whererenderandmountnever happen. - Touch elements only in
mount, and only throughelementOf. - List
chartistas a peer dependency, so your plugin uses the reader’s copy.
Publishing a plugin
Publish your plugin the way Chartist v2 is published: as an ES module with types.
{
"name": "chartist-plugin-highlight-last",
"type": "module",
"exports": {
".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
},
"files": ["dist"],
"keywords": ["chartist", "chartist-plugin"],
"peerDependencies": { "chartist": "^2.0.0" },
"sideEffects": false
}
-
Import, don’t read globals. Import what you need from
chartist. Don’t read the globalChartist: it only exists on pages that load Chartist’s UMD build, or that importchartist/compatfor old plugins. -
No UMD build. Pages without a bundler load ES modules directly from a CDN, and an import map tells the browser where
chartistis, for your plugin’s imports too:<script type="importmap"> { "imports": { "chartist": "https://cdn.jsdelivr.net/npm/chartist@2.0.0-rc.2/dist/index.min.js" } } </script> <script type="module"> import { lineChart } from 'chartist'; import { highlightLast } from 'https://cdn.jsdelivr.net/npm/chartist-plugin-highlight-last/dist/index.js'; lineChart('#chart', data, { plugins: [highlightLast()] }); </script>Chartist still publishes a UMD build and a global
Chartist, but only so that old pages and the plugins of 0.x keep working. They are for backwards compatibility, not something new plugins should build on. -
Name it
chartist-plugin-…and add thechartist-pluginkeyword, so people find it on npm. -
Work in
composeandscenewhere you can. Then your plugin also draws when a chart is rendered on a server, and the plugins page can tag it as SSR-capable.
Don’t forget to tell us about your plugin. Once it’s on npm, open a pull request that adds it to the plugins page, where readers find the community’s plugins (the list is apps/docs/src/data/plugins.ts), or show it in GitHub Discussions.
In the API reference: events and plugins and the scene.
Next: render charts on a server, or animate them.