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. The first five build the chart’s scene, a plain tree of SVG nodes, without touching the page; the last two put it there:
Each phase 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, inset } |
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, cause, previous, changes } |
The renderer has written the SVG, or changed it in place |
mount |
{ scene, output, cause, previous, changes, elementOf, animation } |
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. By render and mount, the SVG exists: render listeners only watch, and a mount listener may return a function that undoes what it did (see Cleaning up). Other return values of theirs are ignored.
render and mount also say why the chart rendered and what the render did: cause is create, hydrate (a chart that took server markup over), update, resize, responsive or fonts, previous is the previous render’s scene, and changes lists every node that was added, updated or removed. mount also has the animation timing when the chart animates its update. Live updates has the details.
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.
The layout box
layout measures the chart from the outside in, a bit like CSS’s box model: the padding, the room layers reserve, the axes, and in the middle the area the layers draw in.
- Chart:
width×heightin the payload. - Padding:
chartPadding. - Reserved room: what layers reserve with
inset(), reported asinsetand only there when a layer reserved some. Each layer draws in its own boxes, itsinsetArea.paddingin the payload covers the padding and this room together. - Axis bands: each axis’s
offset, holding its tick labels. Axes on the same side stack outward in the order they’re listed, and so does the reserved room. - Drawing area:
area, what’s left. Alayoutlistener can return a smaller one to make room for something of its own, but a layer does that better withinset(): see Custom layers.
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 color from mustard yellow to red:
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: mustard yellow for low, red 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 red = Math.round(share * 100);
return node.attr({
style: `stroke: color-mix(in oklab, #c8102e ${red}%, #f4c63d)`
});
});
}
};
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.
Cleaning up
What a plugin sets up outside the chart has to go when the chart goes. install may return a function, and destroy() calls it once, after the chart’s SVG is removed. detach() doesn’t: a detached chart keeps its SVG and its plugins.
import type { ChartPlugin } from 'chartist';
/** Logs the chart's size while the Shift key is down. */
export const shiftToMeasure: ChartPlugin = {
name: 'shift-to-measure',
install(chart) {
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
const box = chart.container?.getBoundingClientRect();
console.log(`${box?.width} x ${box?.height}`);
}
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}
};
A mount listener may return a function too, for what it did to the elements of one render. It runs before the next render touches the SVG, and on destroy(). Use it when the next render needs a clean slate, such as a title with a point’s value. For what you set up once per element, like event listeners, act on the add changes instead. Live updates shows both.
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, type ChartEvents, 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) {
// What each bar element stands for in the latest render.
const bars = new WeakMap<Element, ChartEvents['bar:select']>();
chart.on('mount', ({ changes, elementOf }) => {
for (const { op, node: bar } of changes) {
const element = elementOf(bar);
if (op === 'remove' || bar.role !== 'bar' || !element) {
continue;
}
bars.set(element, {
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
});
// New elements only: an element the renderer kept has its handler.
if (op === 'add') {
element.addEventListener('click', () => {
const selected = bars.get(element);
if (selected) {
chart.emit('bar:select', selected);
}
});
}
}
});
}
};
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, and only on elements that are new in the page: those of add changes. With the default renderer, every render draws a new SVG, so every element is new, and the old ones go away with their handlers. With liveDomRenderer(), a bar can stay the same element for many renders, and a handler added on every render would fire once for each of them. What a kept bar stands for can change, so the plugin notes it per element from add and update changes, and the handler reads it when it fires.
Phase names are reserved: emitting compose or mount throws. A prefix such as bar: keeps your names apart from other plugins’.
Plugins for live updates
A chart drawn with liveDomRenderer() keeps its elements from one render to the next. That’s good news for plugins: what they add to an element stays, hover and focus survive an update, and their own elements can move with the chart instead of popping in again. It asks a little more of them, though.
This tooltip plugin shows the value of the point under the pointer. It’s here to show how a plugin works with live updates; for tooltips on your charts, use @chartist-js/tooltips. The chart gets a new value every second, and the tooltip stays on its point, shows the point’s new value and moves in step with the chart’s update animation. Press Edit and hover a point:
import 'chartist/styles.css';
import './style.css';
import { lineChart, type ChartPlugin, type UpdateAnimation } from 'chartist';
import { liveDomRenderer, smilAnimations } from 'chartist/live';
// Shows the value of the point under the pointer, and stays on it
// while the chart updates.
const tooltip: ChartPlugin = {
name: 'tooltip',
install(chart) {
const tip = document.createElement('div');
tip.className = 'tooltip';
tip.hidden = true;
chart.container?.append(tip);
let point: Element | undefined;
const show = (animation?: UpdateAnimation) => {
if (!point?.isConnected) {
tip.hidden = true;
return;
}
const from = tip.hidden ? '' : tip.style.transform;
const to = `translate(${point.getAttribute('x1')}px, ${point.getAttribute('y1')}px)`;
tip.textContent = point.getAttribute('ct:value');
tip.style.transform = to;
tip.hidden = false;
// Move along with the point, with the chart's timing.
if (animation && from && from !== to) {
tip.animate({ transform: [from, to] }, animation);
}
};
chart.on('mount', ({ changes, elementOf, animation }) => {
// New points only: kept ones have their listeners already.
for (const change of changes) {
const element = elementOf(change.node);
if (change.op !== 'add' || change.node.role !== 'point' || !element) {
continue;
}
element.addEventListener('pointerenter', () => {
point = element;
show();
});
element.addEventListener('pointerleave', () => {
point = undefined;
tip.hidden = true;
});
}
// The hovered point may have a new value and place.
show(animation);
});
// The tooltip isn't part of the chart's SVG: remove it on destroy().
return () => tip.remove();
}
};
let visitors = [12, 15, 14, 18, 21, 19, 23, 22];
const chart = lineChart(
'#chart',
{ series: [visitors] },
{
renderer: liveDomRenderer({ animations: smilAnimations() }),
plugins: [tooltip],
low: 0
}
);
// A new count of visitors on the site every second.
setInterval(() => {
const last = visitors[visitors.length - 1];
const next = Math.max(0, last + Math.round(Math.random() * 8 - 4));
visitors = [...visitors.slice(1), next];
chart.update({ data: { series: [visitors] } });
}, 1000);New elements, kept elements
mount comes after every render, and in a live chart most of its elements were there before. Add listeners on add changes only, as the tooltip does, or they pile up on kept elements. Read what an element shows when you need it (the tooltip reads ct:value when it shows), not when you add the listener: a kept point can get a new value. And return a cleanup function from install for what lives outside the SVG, like the tooltip’s element.
Keys for your own nodes
Nodes a plugin adds are matched between renders too, so they keep their elements. Without a key, a node is matched by its element name, its first class and its place among siblings like it. Give it a key with node() when that’s not enough, for nodes that come and go or change their order:
import { node, type ChartPlugin } from 'chartist';
/** Writes the last value of each line next to its end. */
export const lastValue: ChartPlugin = {
name: 'last-value',
install(chart) {
chart.on('compose', series => {
if (series.role !== 'series') {
return;
}
const points = series.children.filter(child => child.role === 'point');
const last = points.at(-1);
if (!last) {
return;
}
const { x1, y1 } = last.attrs;
return series.append(
node('text', { x: Number(x1) + 8, y: y1 }, 'ct-last-value', {
key: `last-value/${series.ctx.seriesIndex}`
}).text(String(last.attrs['ct:value']))
);
});
}
};
Keyframes
With update animations, numbers move from their old to their new value, and paths with the same commands move point by point. That’s right for most nodes. For a node that should move differently, such as a needle that turns around its center, give the plugin keyframes(). The renderer asks it once per update, with the progress points of the chart’s easing: 0 is the old node, 1 the new one, and points beyond them come from an easing that overshoots. The plugin returns one set of attributes per point, or nothing for the default:
import type { ChartPlugin } from 'chartist';
/**
* Turns the needle a gauge plugin draws, instead of morphing its path.
* The gauge gives the needle's path its angle in `ct:angle`.
*/
export const turningNeedle: ChartPlugin = {
name: 'turning-needle',
install() {},
keyframes(previous, next, progress) {
if (!next.classes.includes('ct-needle')) {
return undefined;
}
const from = Number(previous.attrs['ct:angle']);
const to = Number(next.attrs['ct:angle']);
return progress.map(p => ({
transform: `rotate(${from + (to - from) * p} 100 100)`
}));
}
};
Plugins are asked first, in their order, then the layer that drew the node, so a plugin can also change how a built-in node moves. Custom layers can have keyframes() too.
Animate in step
When the chart animates an update, mount gets its animation: the duration in milliseconds and the easing as a CSS string. Animate what your plugin draws with it, and it moves with the chart. For HTML elements, pass it to the Web Animations API as the tooltip does, element.animate(frames, animation). For SVG elements, Svg#animate() takes the CSS easing as it is. When the chart doesn’t animate (a resize, domRenderer(), or a reader who prefers less motion), animation is absent, and your plugin doesn’t animate either.
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. - Set elements up on their
addchange, or return a cleanup function frommount. Then the plugin works with both renderers; see Plugins for live updates. - 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/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, the scene and chartist/live.
Next: render charts on a server, or animate them.