Chartist

Search

Find a guide, an API entry or an example.

Try

Server rendering

Build a chart's scene without a browser and turn it into SVG markup, for server-rendered pages, PDFs, email and images.

A Chartist chart doesn’t need a browser until the very end. The pipeline builds a scene, a plain tree of SVG elements, and only the last step writes it into the page. Stop before that step, and you can render the scene to a string anywhere JavaScript runs: in Node.js, in a server bundle, in a worker.

Two outputs

What you render depends on where the markup goes:

  • A self-contained SVG image, from toScene() and renderToString(): a picture of the chart for files, PDFs, email, social images or a static chart in a page. It carries its inline styles, since there’s often no stylesheet where it ends up. This page is about it.
  • Hydration markup, from renderForHydration(): a page fragment that the browser takes over and keeps running, with updates, plugins and animations. It has no inline styles, needs Chartist’s stylesheet in the page and passes a strict style-src policy. See SSR with hydration.

Two functions

toScene(config, size) runs the pipeline from resolve to scene and returns the scene. It takes the same config as chart(), with your plugins and listeners, plus the size to lay out at. It touches no DOM, timers or globals.

renderToString(scene) turns a scene into standalone SVG markup, namespaces included. Both come from chartist/server, the entry point for rendering without a DOM; toScene() is the one from chartist, re-exported there so server code needs one import. In 2.0, renderToString() came from chartist: import it from chartist/server now.

import { lineChartConfig } from 'chartist';
import { renderToString, toScene } from 'chartist/server';

export function weekChart(values: number[]): string {
  const scene = toScene(
    lineChartConfig({
      labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'],
      series: [values]
    }),
    { width: 600, height: 300 }
  );
  return renderToString(scene);
}

The presets (lineChart() and friends) draw into an element. For their charts as a string, lineChartConfig(), barChartConfig() and pieChartConfig() build the config a preset draws, from the same data and options: toScene(lineChartConfig(data, options), size). toScene() takes any config of chart(), so composed charts with their own axes and layers render the same way.

The browser can run the same code. This example builds the scene with toScene() and shows its markup from renderToString() below the chart. The chart itself comes from the same scene through domRenderer(): in a page, the DOM renderer creates the elements, so nothing goes through innerHTML, which a strict Content Security Policy can block.

src/main.ts
import 'chartist/styles.css';
import './style.css';
import { domRenderer, lineChartConfig, toScene } from 'chartist';
import { renderToString } from 'chartist/server';

// The same code runs on a server: no DOM needed up to here.
const scene = toScene(
  lineChartConfig(
    {
      labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'],
      series: [
        [12, 9, 7, 8, 5],
        [2, 1, 3.5, 7, 3]
      ]
    },
    { viewBox: { width: 400, height: 250 } }
  ),
  { width: 400, height: 250 }
);

// On a server, the scene becomes SVG markup: a string for a response or
// a file.
const markup = renderToString(scene);

// In the browser, the DOM renderer draws the same scene without parsing
// markup, so the page needs no innerHTML (which a strict Content Security
// Policy can block). The string is shown as text below the chart.
const container = document.querySelector('#chart');
if (container) {
  domRenderer().render(scene, container);
}
const source = document.querySelector('#markup');
if (source) {
  source.textContent = markup;
}
Live

Size

A server has no container to measure, so you pass the size: the scene is laid out at width × height, and the root svg gets those as its width and height. To let the markup scale with its container, add a viewBox of the same size to the config, as the example does, and size the SVG with CSS:

.chart svg {
  width: 100%;
  height: auto;
}

Measuring text

SVG labels (see below) are wrapped into lines and aligned by Chartist, so it needs to know how wide text is. In the browser, it measures the text with the page’s fonts. On a server, toScene() estimates it: each character is 0.6 em wide and a line 1.2 em high, at 12px unless told otherwise. That’s close enough for numbers and short labels.

When you know your fonts, pass a measureText function in the size, next to width and height. It gets the text and options with the fontSize (and the label’s classes), and returns a width and height in px:

import type { MeasureText } from 'chartist';

// Wider glyphs than the estimate, for a wide font.
export const measureText: MeasureText = (text, options = {}) => {
  const fontSize = options.fontSize ?? 12;
  return { width: text.length * fontSize * 0.7, height: fontSize * 1.25 };
};

Then call toScene(config, { width: 600, height: 300, measureText }).

SVG or HTML labels

Chartist draws axis labels in one of two ways, set with the labels option:

  • html: a <span> inside a <foreignObject>, as 1.x did. CSS lays them out, wraps long text and aligns it with flexbox. Only browsers draw them.
  • svg: an SVG <text>, wrapped and aligned by Chartist. Every SVG viewer draws these.

The renderer picks the default: chart() and the presets draw HTML labels in the page, and toScene() draws SVG labels, because string output often ends up in tools without HTML support. Set labels to choose for yourself, in the config (labels: 'html') or in the size of toScene(). The config wins.

axisLabels() takes labels too, for one layer, and so do lineChart() and barChart() as an option. The 1.x classes always draw HTML labels, and pie and donut labels are always SVG text.

Where the markup goes

The markup has the same classes as a chart in the page, but no styles. Where it ends up decides how it gets them.

Server-rendered pages. Put the string into your HTML and load chartist/styles.css (or your own chart styles) on the page, like for any chart. Since the page is HTML, HTML labels work, if you want them. If the chart should run in the page once its script loads, render it for SSR with hydration instead.

PDFs. If you print HTML to PDF with a headless browser, it’s the same as a web page. Other PDF tools read the SVG on its own: there, use SVG labels and write the styles into the SVG.

Email and images. Many email clients don’t show SVG at all, so send a PNG. Convert the markup with an SVG rasterizer, for example in a Node.js service, and use SVG labels with the styles written into the SVG. Rasterizers don’t draw HTML, so HTML labels would be missing.

Styles in the SVG

Tools that read SVG on its own know less CSS than browsers. Chartist’s stylesheet colors each series through a custom property (stroke: var(--ct-color), set on the series group) and the grid and labels with light-dark(). Rasterizers such as resvg support neither, and draw the lines without their color. So don’t copy the stylesheet into the SVG. Write it into the scene with applyStyles():

import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { lineChartConfig } from 'chartist';
import { applyStyles, renderToString, toScene } from 'chartist/server';

// Chartist's stylesheet, from the package, and your own after it.
const require = createRequire(import.meta.url);
const css = [
  readFileSync(require.resolve('chartist/styles.css'), 'utf8'),
  '.ct-series-a { --ct-color: #0b7285; }'
];

const scene = toScene(
  lineChartConfig({
    labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'],
    series: [[12, 9, 7, 8, 5]]
  }),
  { width: 600, height: 300 }
);

export const markup = renderToString(applyStyles(scene, css));

applyStyles(scene, css) matches the rules against the scene as a browser would and writes the values each element gets as SVG attributes, such as stroke="#0b7285" on the line. Custom properties are resolved per element, inherited through the scene, and light-dark() takes its light value. Pass { colorScheme: 'dark' } for the dark values and the @media (prefers-color-scheme: dark) rules. The classes stay, and the scene you pass in isn’t changed.

How Chartist reads your CSS

applyStyles() uses computeStyles(), a CSS engine of its own: deliberately small, made for the CSS charts are styled with, and without dependencies. It isn’t a browser, and some CSS it leaves out. What it reads:

  • type, class and attribute selectors, :root, :not(), :is() and :where(), and the descendant and child combinators;
  • the cascade: !important, an element’s own style, specificity, order;
  • custom properties, inherited through the scene, with var() fallbacks, and light-dark();
  • @media (prefers-color-scheme) for the color scheme you pass, and @supports and @layer blocks, in place (without layer order);
  • the page around the chart: rules for :root, html and body apply, so a theme’s custom properties on :root reach the chart, and inherited properties such as color and font-family set there reach the svg.

What it doesn’t:

CSS What happens
Sibling and structural selectors (+, ~, :nth-child()), ids, :hover The rule is left out.
Nested rules Left out.
The font shorthand Left out: write font-family, font-size and the others.
At-rules other than the ones above (@media print, @container, @import) Their rules are left out.
calc(), relative units (rem, em, %), newer color functions Written as they are. Browsers read them, many rasterizers don’t: use px and plain colors.
HTML labels (foreignObject) Left as they are. Rasterizers don’t draw them: use SVG labels.

Pass onSkip to computeStyles() to find out what it left out, with the reason:

import { lineChartConfig } from 'chartist';
import { applyStyles, computeStyles, toScene } from 'chartist/server';

const css = '.ct-label { font: 14px Georgia; } .ct-line:hover { stroke: red; }';
const scene = toScene(lineChartConfig({ series: [[5, 2, 4]] }), {
  width: 600,
  height: 300
});

const styles = computeStyles(scene, css, {
  // shorthand: font: 14px Georgia
  // selector: .ct-line:hover
  onSkip: ({ reason, text }) => console.warn(`${reason}: ${text}`)
});
export const styled = applyStyles(scene, styles);

Another CSS engine

For CSS beyond that, compute the styles with something that knows all of it: a browser. applyStyles() takes any function that gives a scene node its values (a ComputedStyles), not only the one from computeStyles(). This one renders the markup with your stylesheet in headless Chromium, with Playwright, and reads each element’s computed style:

import type { SceneNode } from 'chartist';
import { chromium } from 'playwright';
import {
  applyStyles,
  renderToString,
  type ComputedStyles
} from 'chartist/server';

// The properties to read: the ones your stylesheet sets.
const properties = ['fill', 'stroke', 'stroke-width', 'opacity', 'font-size'];

export async function browserStyles(
  scene: SceneNode,
  css: string
): Promise<ComputedStyles> {
  const browser = await chromium.launch();
  try {
    const page = await browser.newPage();
    await page.setContent(`<style>${css}</style>${renderToString(scene)}`);
    // The computed values of every element, in document order.
    const values = await page.$$eval(
      'svg, svg *',
      (elements, names) =>
        elements.map(element => {
          const style = getComputedStyle(element);
          return Object.fromEntries(
            names.map(name => [name, style.getPropertyValue(name)])
          );
        }),
      properties
    );
    // The scene's nodes are in the same order.
    const nodes: SceneNode[] = [];
    const visit = (node: SceneNode) => {
      nodes.push(node);
      node.children.forEach(visit);
    };
    visit(scene);
    const byNode = new Map(nodes.map((node, index) => [node, values[index]]));
    return node => byNode.get(node);
  } finally {
    await browser.close();
  }
}

// const styled = applyStyles(scene, await browserStyles(scene, css));

It costs a browser on your server, and it’s slower: use it when the small engine doesn’t cover your CSS.

In the API reference: rendering and chartist/server.

Next: keep a server-rendered chart running in the page with SSR with hydration.