Chartist

Search

Find a guide, an API entry or an example.

Try

Events and plugins

Every chart is built in phases, and each phase is an event you can listen to and change. Plugins package those listeners so you can share them.

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, gridBackground or label
  • ctx: its data, such as seriesIndex, valueIndex, value and meta
  • key: a stable id like 0/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:

src/main.ts
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] }
);
Live

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:

src/main.ts
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}`;
  }
});
Live

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 compose and scene, not the DOM. Then it also works when the chart is rendered to a string, where render and mount never happen.
  • Touch elements only in mount, and only through elementOf.
  • List chartist as 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 global Chartist: it only exists on pages that load Chartist’s UMD build, or that import chartist/compat for old plugins.

  • No UMD build. Pages without a bundler load ES modules directly from a CDN, and an import map tells the browser where chartist is, 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 the chartist-plugin keyword, so people find it on npm.

  • Work in compose and scene where 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.