Chartist

Search

Find a guide, an API entry or an example.

Try

Live updates

Give a chart new data through its handle, and let it keep its SVG with liveDomRenderer(), so elements stay, plugins keep what they added and updates can animate.

lineChart(), barChart(), pieChart() and chart() draw the chart right away and return a handle. Keep it: it’s how you give the chart new data, and how you tell it you’re done with it.

Here’s a temperature sensor that reports every second. The chart drops the oldest reading, adds the new one and keeps its SVG while doing it. The line under the chart counts what each update did to the SVG: almost every element is kept, many of them with new positions, and only the oldest reading’s elements are removed and the new one’s added. The axis follows the readings, so when their range changes, its grid lines and labels change too. Press Edit to change the code:

src/main.ts
import 'chartist/styles.css';
import './style.css';
import { lineChart, type SceneNode } from 'chartist';
import { liveDomRenderer } from 'chartist/live';
import { firstReadings, onReading } from './readings';

const readout = document.querySelector('#changes')!;
const count = (node: SceneNode): number =>
  1 + node.children.reduce((sum, child) => sum + count(child), 0);

const chart = lineChart('#chart', firstReadings, {
  // Keep the SVG, and know each reading by its label.
  renderer: liveDomRenderer({ key: 'label' }),
  showArea: true,
  on: {
    // What each render did to the SVG's elements.
    mount: ({ scene, changes, cause }) => {
      const total = count(scene);
      if (cause === 'create') {
        readout.textContent = `Drew ${total} elements.`;
        return;
      }
      const tally = { add: 0, update: 0, remove: 0 };
      for (const change of changes) {
        tally[change.op] += 1;
      }
      readout.textContent =
        `Kept ${total - tally.add} of ${total} elements (${tally.update} changed), ` +
        `added ${tally.add}, removed ${tally.remove}.`;
    }
  }
});

// A new reading every second: the oldest one goes, the new one comes.
onReading(data => chart.update({ data }));
Live

liveDomRenderer(), the key option, animations and what changed are what the rest of this guide is about. More live charts are in the examples.

The handle’s methods:

Method What it does
update(changes) Merges the changes into the config and draws the chart again
detach() Stops following the container’s size; the chart stays as it is
destroy() Detaches, removes the SVG and removes every listener
on(), off() Adds and removes listeners, see Events and plugins
setOptions() Presets only: changes the options and draws again, see new options

New data

Pass update() the parts of the config that changed. The merge is shallow: each key you pass replaces that key, the rest stays. update({ data }) replaces all of the data, labels included, so pass the whole data object, not only the series that changed. The same goes for axes and layers in a chart() config.

import { autoScaleAxis, cartesianAxes, chart, line, stepAxis } from 'chartist';

const labels = ['Mon', 'Tue', 'Wed'];
const handle = chart('#chart', {
  data: { labels, series: [[1, 3, 2]] },
  axes: cartesianAxes([
    { id: 'x', side: 'bottom', type: stepAxis() },
    { id: 'y', side: 'left', type: autoScaleAxis() }
  ]),
  layers: [line()]
});

// New data; the axes and layers stay.
handle.update({ data: { labels, series: [[2, 4, 3]] } });

// Another look; the data stays.
handle.update({ layers: [line({ showArea: true, showPoint: false })] });

Every update runs the whole pipeline again, and your plugins and listeners stay. They aren’t part of what update() takes: add and remove listeners with on() and off().

New options

update() takes the config of chart(), so on a preset chart it replaces the data, but options like showArea or stackBars aren’t part of it. The presets’ handles have setOptions() for those. The options you pass extend the current ones; with { replace: true } they start from the defaults instead, and the options you gave before are gone:

import { lineChart } from 'chartist';

const chart = lineChart(
  '#chart',
  { series: [[1, 3, 2, 5]] },
  { showPoint: false }
);

// Areas too; showPoint: false stays.
chart.setOptions({ showArea: true });

// From the defaults: points again, and areas.
chart.setOptions({ showArea: true }, { replace: true });

Responsive options still apply on top, and new data from update({ data }) stays when they change. plugins, on and renderer are set at creation.

Keep the SVG

What a render does to the page depends on the chart’s renderer:

  • domRenderer(), the default, draws a new SVG on every render and replaces the old one: on an update, a resize, a responsive change, or when web fonts finish loading. It’s small, and right for charts that rarely change. But the elements are new each time: a hovered bar loses its :hover, a focused point its focus, what a plugin added to an element is gone, and CSS animations play again.
  • liveDomRenderer(), from chartist/live, draws the SVG once and then changes it in place. An element whose value is still in the data stays the same element, and only the attributes, classes and text that changed are set. New values get new elements, gone ones are removed. The result is the same SVG a fresh chart would draw.

Opt in per chart with the renderer option, on the presets and on chart():

import { barChart } from 'chartist';
import { liveDomRenderer } from 'chartist/live';

barChart(
  '#chart',
  { labels: ['Q1', 'Q2', 'Q3', 'Q4'], series: [[5, 4, 3, 7]] },
  { renderer: liveDomRenderer() }
);

Reach for it when a chart updates while people look at it: dashboards, streams, charts with tooltips or other interactive plugins. A chart that’s drawn once doesn’t need it.

Importing chartist/live

With a bundler, import it next to chartist:

import { lineChart } from 'chartist';
import { liveDomRenderer } from 'chartist/live';

lineChart('#chart', { series: [[1, 3, 2]] }, { renderer: liveDomRenderer() });

Without one, load live.min.js from the same CDN folder as index.min.js. It imports ./index.min.js, so both share one copy of Chartist:

<script type="module">
  import { lineChart } from 'https://cdn.jsdelivr.net/npm/chartist@2/dist/index.min.js';
  import { liveDomRenderer } from 'https://cdn.jsdelivr.net/npm/chartist@2/dist/live.min.js';

  lineChart('#chart', { series: [[1, 3, 2]] }, { renderer: liveDomRenderer() });
</script>

Which value is which

To keep an element, the renderer has to know which value of the new data it stands for. The key option says what identifies a value:

  • 'index', the default: its position in its series. The third value stays the third value. Right when the data has the same values in the same order, with new numbers.
  • 'label': the label at its position. Right for a sliding window like the sensor above, where every reading moves one place to the left. With index keys, each point would stay where it is and take its neighbor’s value; with label keys, the point of 5s keeps its element and moves left.
  • A function that gets { seriesIndex, valueIndex, value, label, meta } and returns a string or a number. Right when values carry an id, or when they change their order:
import { barChart } from 'chartist';
import { liveDomRenderer } from 'chartist/live';

// Each value carries its team's id; the bars are sorted by score.
barChart(
  '#chart',
  {
    labels: ['Otters', 'Owls', 'Ants'],
    series: [
      [
        { value: 12, meta: 'otters' },
        { value: 9, meta: 'owls' },
        { value: 4, meta: 'ants' }
      ]
    ]
  },
  { renderer: liveDomRenderer({ key: ({ meta }) => String(meta) }) }
);

Series are matched by their index, and grid lines and axis labels by their tick value: when the scale changes from 0 to 10 to 0 to 15, the lines of 0, 5 and 10 move and the one of 15 is added. Values with the same key in a series are matched in their order, so repeated labels work like index keys. The key only decides which elements are kept, never what’s drawn.

What changed

The render and mount events say why the chart rendered and what the render did, for every renderer:

  • cause: create for the first render, then update (from update() and setOptions()), resize, responsive or fonts;
  • previous: the previous render’s scene, if there was one;
  • changes: one entry per node, in document order. add for a new node, update for a node that changed (with its previous node), remove for a node that’s gone. In mount, elementOf() gives each entry’s element, for a remove the one just taken out of the page.

With domRenderer(), every render is a replacement: every previous node is removed and every new one added. With liveDomRenderer(), the changes are what it changed.

That makes add entries the place to set up an element once. Listeners added in mount to every element would pile up on elements that stay, so add them to new elements only. This works the same with both renderers:

import { barChart } from 'chartist';
import { liveDomRenderer } from 'chartist/live';

barChart(
  '#chart',
  { labels: ['Q1', 'Q2', 'Q3', 'Q4'], series: [[5, 4, 3, 7]] },
  {
    renderer: liveDomRenderer(),
    on: {
      mount: ({ cause, changes, elementOf }) => {
        for (const change of changes) {
          if (change.op === 'add' && change.node.role === 'bar') {
            elementOf(change.node)?.addEventListener('click', () =>
              console.log('clicked a bar')
            );
          }
        }
        console.log(`${cause}: ${changes.length} changes`);
      }
    }
  }
);

Cleaning up

Some things can’t be set up once: a title that shows a value changes with the value. A mount listener can return a function that undoes what it did. It runs right before the next render touches the SVG, and on destroy():

import { findAll, lineChart } from 'chartist';
import { liveDomRenderer } from 'chartist/live';

const SVG = 'http://www.w3.org/2000/svg';

lineChart(
  '#chart',
  { series: [[1, 3, 2, 4]] },
  {
    renderer: liveDomRenderer(),
    on: {
      mount: ({ scene, elementOf }) => {
        // A tooltip with its value on every point.
        const titles = findAll(scene, { role: 'point' }).map(point => {
          const title = document.createElementNS(SVG, 'title');
          title.textContent = String(point.attrs['ct:value']);
          elementOf(point)?.append(title);
          return title;
        });
        // Gone before the next render adds new ones.
        return () => titles.forEach(title => title.remove());
      }
    }
  }
);

Plugins can do the same, and their install can return a cleanup function too, for what they set up outside the chart. See Events and plugins.

Animate updates

With smilAnimations(), updates move: bars grow and shrink, points and lines slide to their new place, pie slices turn. Updates take 300 ms with ease-out. The timing is part of the chart’s look, so it’s in CSS: set --ct-update-duration and --ct-update-easing on the chart or its container to change it, or --ct-update-duration: 0s to turn the animations off.

src/style.css
#chart {
  /* Updates move the bars from their old values to the new ones. */
  --ct-update-duration: 700ms;
  /* A curve that overshoots a little, then settles. */
  --ct-update-easing: cubic-bezier(0.34, 1.56, 0.64, 1);
}
Live

--ct-update-easing takes any CSS easing: the keywords (ease-out is the default), cubic-bezier() curves, including ones that overshoot like the one above, linear() and steps(). The animations run with SMIL, which reaches what CSS can’t: bars and points are <line> elements, and the ends of a line are attributes, not CSS properties. SMIL moves them, and paths, in every browser.

A few things to know:

  • The attributes hold the new values at once. The animation is drawn on top of them, so whatever you read from the DOM is the final state.
  • An update during an animation continues from where the elements are drawn, without a jump. Pie slices start from their previous shape.
  • Only update and responsive renders animate. A resize, the first render and loaded fonts draw the new state at once.
  • Nothing moves for people who ask for less motion. Under prefers-reduced-motion: reduce, updates aren’t animated, whatever the CSS says.
  • Firefox doesn’t animate HTML labels. It runs no SMIL inside a foreignObject, so there the axis labels move to their new place at once. SVG text labels (labels: 'svg') move in every browser.

Pies animate too. Each slice turns to its new size around the pie, and its label moves around with it. With key: 'label', a slice keeps its element even when the order of the parties changes:

src/main.ts
import 'chartist/styles.css';
import './style.css';
import { pieChart } from 'chartist';
import { liveDomRenderer, smilAnimations } from 'chartist/live';
import { firstCount, onCount, parties } from './count';

let votes = firstCount;
const total = () => votes.reduce((sum, value) => sum + value, 0);

const chart = pieChart(
  '#chart',
  { labels: parties, series: votes },
  {
    // Slices are known by their party, and turn to their new share.
    renderer: liveDomRenderer({ key: 'label', animations: smilAnimations() }),
    // Each slice shows its share.
    labelTransform: (label, index) =>
      `${label}\n${Math.round((votes[index] / total()) * 100)}%`
  }
);

// The next district's results.
onCount(count => {
  votes = count;
  chart.update({ data: { labels: parties, series: votes } });
});
Live

A gauge is a donut on half a ring. Its second slice is the rest of the scale, drawn as a faint track in CSS, so the load moves along the ring:

src/main.ts
import 'chartist/styles.css';
import './style.css';
import { pieChart } from 'chartist';
import { liveDomRenderer, smilAnimations } from 'chartist/live';
import { firstLoad, onLoad } from './load';

// The load, and the rest of the scale as a track, on a half ring: a total
// of 200 leaves the lower half empty.
const series = (load: number) => [load, 100 - load];

const chart = pieChart(
  '#chart',
  { series: series(firstLoad) },
  {
    renderer: liveDomRenderer({ animations: smilAnimations() }),
    donut: true,
    donutWidth: 40,
    startAngle: 270,
    total: 200,
    showLabel: false
  }
);

onLoad(load => chart.update({ data: { series: series(load) } }));
Live

How to animate your own layers and plugins: keyframes in the plugin guide, and Animations for the rest of the animation story.

Streaming

For data that arrives every second or faster, keep the duration at or below the update interval. Every update starts new animations on top of the running ones, and when they come faster than they end, the chart is always on its way somewhere and never quite there. With a linear easing and the duration of the interval, a stream scrolls without stopping.

Here, four network speed samples a second come in as touching bars, with their running average as a line on top. The samples are known by their number, so they all slide one bar to the left:

src/main.ts
import 'chartist/styles.css';
import './style.css';
import {
  autoScaleAxis,
  axisGrid,
  axisLabels,
  bar,
  cartesianAxes,
  chart,
  line,
  stepAxis
} from 'chartist';
import { liveDomRenderer, smilAnimations } from 'chartist/live';
import { SAMPLES, firstSamples, onSample } from './samples';

// The bars are as wide as one sample, so they touch.
let step = 0;
const container = document.querySelector<HTMLElement>('#chart')!;

const live = chart(container, {
  data: firstSamples,
  axes: cartesianAxes([
    {
      id: 'time',
      side: 'bottom',
      type: stepAxis(),
      showGrid: false,
      showLabel: false
    },
    { id: 'mbps', side: 'left', type: autoScaleAxis(), low: 0, high: 140 }
  ]),
  layers: [
    axisGrid({ axes: ['mbps'] }),
    bar({ series: ['speed'], x: 'time', y: 'mbps' }),
    line({
      series: ['average'],
      x: 'time',
      y: 'mbps',
      align: 'center',
      showPoint: false
    }),
    axisLabels()
  ],
  // Samples are known by their number, so they slide to the left.
  renderer: liveDomRenderer({ key: 'label', animations: smilAnimations() }),
  on: {
    layout: ({ area }) => void (step = area.width / SAMPLES),
    mount: () => container.style.setProperty('--ct-bar-width', `${step}px`)
  }
});

onSample(data => live.update({ data }));
Live

The seismograph in the examples updates every 80 ms.

Data and options at once

update() takes any part of the config, so new data and new layers can arrive in one update. It’s one render, so it’s one animation: every bar moves from where it was to where it goes. These bars switch between grouped and stacked every two and a half seconds, with new numbers each time:

src/main.ts
import 'chartist/styles.css';
import './style.css';
import {
  autoScaleAxis,
  axisGrid,
  axisLabels,
  bar,
  cartesianAxes,
  chart,
  stepAxis
} from 'chartist';
import { liveDomRenderer, smilAnimations } from 'chartist/live';
import { salesData } from './sales';

// The same bars, grouped or stacked: only the layer's option differs.
const layers = (stackBars: boolean) => [
  axisGrid(),
  bar({ stackBars }),
  axisLabels()
];

let stacked = false;
const live = chart('#chart', {
  data: salesData(),
  axes: cartesianAxes([
    { id: 'months', side: 'bottom', type: stepAxis() },
    { id: 'sales', side: 'left', type: autoScaleAxis(), low: 0 }
  ]),
  layers: layers(stacked),
  renderer: liveDomRenderer({ animations: smilAnimations() })
});

// New numbers and the other layout, in one update: every bar moves from
// where it was to where it goes.
setInterval(() => {
  stacked = !stacked;
  live.update({ data: salesData(), layers: layers(stacked) });
}, 2500);
Live

The axis moves with them: its range grows for the stacks, and the grid lines and labels of the ticks that stay slide to their new place. On a preset, options change with setOptions() and data with update(), which are two renders: the second one picks up where the first one’s animation is.

What it costs

Live updates are opt-in, in their own entry point, so charts that don’t use them don’t carry them. Minified and gzipped, liveDomRenderer() adds about 1.4 kB to a chart, and smilAnimations() about 4 kB more. What the core needs for them (the previous render for renderers, cause and changes, cleanup functions) is about 0.6 kB, in every chart.

Redrawing on resize

You don’t need to listen to resize on the window. A chart watches its container with a ResizeObserver and lays itself out again when the container’s size changes, whether the window, a sidebar or a CSS grid changed it. Only the layout and later phases run again; the config and data are reused.

Two things make this work: the container needs a size, and the chart needs room to follow it. Sizing charts covers both. For different options at different sizes, see Responsive sugar topping.

Charts with SVG labels also lay out again when web fonts finish loading, since Chartist may have measured the labels before the font arrived.

Detach and destroy

detach() stops a chart from following its container (and, for presets, the media queries of their responsive options). The SVG and your listeners stay, and update() still draws. Use it to freeze a chart, for example before printing or while you animate its container.

destroy() is for when the chart goes away: it detaches, runs the cleanup functions, removes the SVG from the container and removes every listener. Calling it twice is safe. In a component framework, destroy the chart where the component cleans up, so no observer keeps a removed element alive:

import { lineChart, type ChartHandle } from 'chartist';

let handle: ChartHandle | undefined;

export function mount(element: HTMLElement) {
  handle = lineChart(element, { series: [[1, 3, 2]] });
}

export function unmount() {
  handle?.destroy();
  handle = undefined;
}

In the API reference: the chart handle and chartist/live.

Next: animate charts with CSS, or write a plugin.