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. The first five build the chart’s scene, a plain tree of SVG nodes, without touching the page; the last two put it there:

The pipeline of a chartA chart renders in seven phases, each with an event. A first render, an update or a change of responsive options starts at resolve, which applies the defaults and responsive overrides to the config. prepare turns the data into series, layout measures the chart and its drawing area and sets up the axes, compose lets each layer draw its nodes, and scene gives the whole tree. These five phases need no DOM, and toScene() stops after scene, for renderToString() on a server. A resize or loaded web fonts start again at layout. render hands the scene to the renderer, which draws a new svg, patches the one it has, or takes server markup over. mount runs with the elements in the page, where plugins attach to them and return cleanups.first render, update(),responsive optionsresize, web fontsresolvethe config, with defaultsand responsive overridespreparethe data as serieslayoutsize and drawing area,then the axescomposeeach layer draws its nodes:lines, points, bars, labelsscenethe whole tree, its root the svgrenderthe renderer draws a new svg,patches it, or takesserver markup overmountthe elements are in the page:plugins attach, return cleanupstoScene() stops here,for renderToString()on a serverPuts it into the pageBuilds the scene, without a DOM
A render runs the phases top to bottom, each with an event. A resize starts again at layout, and toScene() stops after scene.

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.

The layout box of a chartThe layout phase measures a chart from the outside in, like the CSS box model. The outer box is the chart, layout.width by layout.height. Inside it is the padding from chartPadding. Inside that is the room layers reserve with inset(), reported as layout.inset, each layer drawing in its own insetArea. Inside that are the axis bands, each axis's offset, where its tick labels are drawn. In the middle is the drawing area, layout.area, where the layers draw their series.Drawing arealayout.areaChartPaddingReserved roomAxis bands
The layout from the outside in: each box holds the next.
  • Chart: width × height in the payload.
  • Padding: chartPadding.
  • Reserved room: what layers reserve with inset(), reported as inset and only there when a layer reserved some. Each layer draws in its own boxes, its insetArea. padding in 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. A layout listener can return a smaller one to make room for something of its own, but a layer does that better with inset(): 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, 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 color from mustard yellow to red:

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: 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] }
);
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.

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:

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

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:

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

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 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.
  • Set elements up on their add change, or return a cleanup function from mount. Then the plugin works with both renderers; see Plugins for live updates.
  • 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/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, the scene and chartist/live.

Next: render charts on a server, or animate them.