Skip to content

Themes

A theme is one set of design tokens the whole chart reads from: the series palette, the surface and text colors, the base type size, line widths, mark rounding and fill opacity, the semantic up/down colors, and the axis chrome. theme accepts a built-in theme name or a ThemeOptions object.

Building one by hand is optional — the theme builder has every token as a control, with live previews and a JSON export.

Built-in themes

Seven presets ship with the library. THEME_NAMES lists them, so a select needs no hardcoded list:

ts
import { Charts, THEME_NAMES, type ThemeName } from 'grafit-charts';

for (const name of THEME_NAMES) select.append(new Option(name, name));
void chart.updateDelta({ theme: select.value as ThemeName });
NameSurfaceWhat it is
'default'lightthe neutral light theme, used when theme is omitted
'dark'darkthe neutral dark theme
'vibrant'lightsaturated hues, ordered so no adjacent pair collapses under color blindness
'muted'lightlow-chroma palette on a warm surface
'mono'lightone hue from light to dark — for stages and tiers, not unrelated categories
'contrast'lightevery series color clears 3:1, ticks on, solid grid, thicker lines
'midnight'darka navy-cast dark theme, palette stepped for the darker surface

'default' — light (used when theme is omitted):

ts
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Light theme (default)' },
    series: [
      { type: 'bar', xField: 'month', yField: 'desktop', name: 'Desktop' },
      { type: 'line', xField: 'month', yField: 'mobile', name: 'Mobile' },
    ],
    theme: 'default',
  };
}
ts
export function getData() {
  return [
    { month: 'Jan', desktop: 42, mobile: 28 },
    { month: 'Feb', desktop: 49, mobile: 34 },
    { month: 'Mar', desktop: 46, mobile: 41 },
    { month: 'Apr', desktop: 58, mobile: 47 },
    { month: 'May', desktop: 63, mobile: 55 },
    { month: 'Jun', desktop: 60, mobile: 62 },
  ];
}

'dark' — dark:

ts
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Dark theme (dark)' },
    series: [
      { type: 'bar', xField: 'month', yField: 'desktop', name: 'Desktop' },
      { type: 'line', xField: 'month', yField: 'mobile', name: 'Mobile' },
    ],
    theme: 'dark',
  };
}
ts
export function getData() {
  return [
    { month: 'Jan', desktop: 42, mobile: 28 },
    { month: 'Feb', desktop: 49, mobile: 34 },
    { month: 'Mar', desktop: 46, mobile: 41 },
    { month: 'Apr', desktop: 58, mobile: 47 },
    { month: 'May', desktop: 63, mobile: 55 },
    { month: 'Jun', desktop: 60, mobile: 62 },
  ];
}

'contrast' — the accessibility-forward preset, which changes the chrome as well as the colors:

ts
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'High-contrast preset' },
    series: [
      { type: 'bar', xField: 'quarter', yField: 'product', name: 'Product' },
      { type: 'line', xField: 'quarter', yField: 'service', name: 'Service' },
    ],
    // the preset inks up the chrome on its own: ticks on, solid grid, thicker lines
    theme: 'contrast',
  };
}
ts
export function getData() {
  return [
    { quarter: 'Q1', product: 46, service: 28 },
    { quarter: 'Q2', product: 52, service: 34 },
    { quarter: 'Q3', product: 49, service: 45 },
    { quarter: 'Q4', product: 61, service: 52 },
  ];
}

The theme can be switched on the fly — chart.updateDelta({ theme: 'dark' }) re-renders the chart with animation. The demos on this site switch this way along with the page theme (unless the example sets a theme explicitly).

Palettes and color vision

'vibrant', 'contrast' and 'midnight' were checked against a color-vision simulation: no two neighbouring series colors collapse into one under protanopia or deuteranopia. 'muted' sits closer to the limit — good for up to four series, or with value labels on. The palette shared by 'default' and 'dark' predates that check and keeps its published colors for compatibility.

Custom theme

A theme object is baseTheme (the foundation) + palette (series colors, cycled) + params (design tokens) + axis (axis chrome):

ts
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Sales funnel' },
    series: [{ type: 'bar', xField: 'stage', yField: 'count', name: 'Deals', cornerRadius: 6 }],
    legend: { enabled: false },
    // custom theme: palette + design tokens on top of a base theme
    theme: {
      baseTheme: 'dark',
      palette: { fills: ['#27c08d'] },
      params: {
        backgroundColor: '#0d1f1a',
        foregroundColor: '#d8f3e9',
        fontFamily: 'Georgia, serif',
      },
    },
  };
}
ts
export function getData() {
  return [
    { stage: 'Leads', count: 1840 },
    { stage: 'Qualified', count: 1120 },
    { stage: 'Demo', count: 640 },
    { stage: 'Contract', count: 310 },
    { stage: 'Payment', count: 245 },
  ];
}

A color set on a series (fill, stroke) takes precedence over the theme palette.

Design tokens

params holds one value each, applied across every series type at once:

ts
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Design tokens' },
    subtitle: { text: 'one theme, every mark follows' },
    series: [
      { type: 'bar', xField: 'month', yField: 'north', name: 'North' },
      { type: 'line', xField: 'month', yField: 'south', name: 'South', marker: { enabled: true } },
    ],
    theme: {
      baseTheme: 'vibrant',
      params: {
        // one value each, applied across every series type at once
        fontSize: 12,
        strokeWidth: 3,
        cornerRadius: 6,
        fillOpacity: 0.5,
      },
      axis: { tick: true, gridDash: [] },
    },
  };
}
ts
export function getData() {
  return [
    { month: 'Jan', north: 34, south: 22 },
    { month: 'Feb', north: 41, south: 27 },
    { month: 'Mar', north: 38, south: 35 },
    { month: 'Apr', north: 52, south: 39 },
    { month: 'May', north: 57, south: 48 },
    { month: 'Jun', north: 54, south: 56 },
  ];
}

Three of them behave differently from the rest. cornerRadius and fillOpacity are unset by default, because the built-in values differ on purpose — a bar is square while a range bar is rounded, an area fills at 0.35 while a marker fills at 0.85. Leave them out and every mark keeps its own default; set them and they override all of it at once.

fontSize is the base size, 11 by default. Every other label is a fixed offset from it — axis labels sit at the base, the legend and axis titles one step above, the chart title six. Moving the base moves the whole scale and keeps the hierarchy.

Web fonts

Canvas text never triggers a font download by itself: a @font-face family that the browser has not fetched yet would draw — and be measured — with a fallback face. The chart therefore asks the browser for every family its options mention, and once the real faces arrive it lays out and draws again, so labels, axes and the legend end up sized against the font you asked for.

Fonts the page declares later — a lazily loaded CSS chunk, a document.fonts.add() from your own code — are covered too: the chart listens for them and redraws when one of its families lands.

Switching fontFamily to a font that is still loading therefore produces two frames. chart.waitForUpdate() resolves after the second one — await it before getImageDataURL() if you export the chart.

To keep the first frame as the only one, opt out:

js
{
  fonts: { autoReload: false },
}

The chart then draws with whatever face the browser already has and never asks for the missing ones — with a not-yet-loaded family it stays on the fallback, since canvas text triggers no font download on its own.

Axis chrome

ChartOptions.axes is an array, so overrides cannot reach it — that is what the axis block is for. It carries the switches, the metrics and the colors of every axis at once:

ts
theme: {
  baseTheme: 'default',
  axis: {
    tick: true,
    tickSize: 4,
    gridDash: [],
    gridColor: '#eceff3',
    labelSize: 12,
    titleColor: '#1f2733',
  },
}

The three switches are master switches: turning one off silences that chrome everywhere, while leaving it on keeps the usual rule (the value axis gets the grid, the category axis gets the line). To turn the grid on where the rule turned it off, set it on the axis itself.

The colors are optional refinements. Leave color, gridColor and tickColor alone and all three follow params.axisColor; leave labelColor alone and it follows params.mutedColor, titleColor follows params.foregroundColor. Set one and only that element changes.

Legend and tooltip

These are ordinary ChartOptions blocks, so the theme reaches them through overrides.common — there are no separate tokens for them, because two paths to one pixel is worse than one:

ts
theme: {
  baseTheme: 'dark',
  overrides: {
    common: {
      legend: { position: 'right', item: { label: { fontSize: 13 } }, background: { fill: '#1b1f27', cornerRadius: 8 } },
      tooltip: { background: '#11151c', borderColor: '#2b313b', borderRadius: 10 },
    },
  },
}

Everything in LegendOptions and TooltipOptions is available this way, and a chart that sets the same option itself still wins.

Overrides

overrides are partial options layered beneath user options: common — chart-level blocks for all charts, <seriesType>.series — defaults for series of that type. It is the escape hatch for everything tokens cannot express: per-series-type styling and non-style options such as legend.position.

ts
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Overrides: defaults per series type' },
    series: [
      { type: 'bar', xField: 'month', yField: 'desktop', name: 'Desktop' },
      { type: 'line', xField: 'month', yField: 'mobile', name: 'Mobile' },
    ],
    theme: {
      overrides: {
        common: { legend: { position: 'right' } },
        bar: { series: { cornerRadius: 8, fillOpacity: 0.8 } },
        line: { series: { strokeWidth: 3.5, lineDash: [12, 6], marker: { enabled: false } } },
      },
    },
  };
}
ts
export function getData() {
  return [
    { month: 'Jan', desktop: 42, mobile: 28 },
    { month: 'Feb', desktop: 49, mobile: 34 },
    { month: 'Mar', desktop: 46, mobile: 41 },
    { month: 'Apr', desktop: 58, mobile: 47 },
    { month: 'May', desktop: 63, mobile: 55 },
    { month: 'Jun', desktop: 60, mobile: 62 },
  ];
}

Precedence: library defaults < theme tokens < overrides.common < overrides[type].series < explicit options. Tokens sit below overrides because overrides are merged into the options before a renderer ever consults the theme.

ThemeOptions

OptionTypeDescription
baseThemeThemeNamebase theme to build on
palette.fillsColorValue[]series fill colors, by series index
palette.strokesColorValue[]stroke colors (defaults to fills)
palette.sequentialColorValue[]ramp for colorField series and the gradient legend
params.backgroundColorColorValuechart background
params.foregroundColorColorValueprimary text color
params.mutedColorColorValuesecondary text: axis labels, subtitle, legend values
params.axisColorColorValueaxis lines, ticks and grid
params.fontFamilystringfont for all text
params.fontSizePixelsbase label size (11); every other size moves with it
params.strokeWidthPixelsdata line width — line, area and radar strokes
params.lineDashPixels[]dash pattern of data lines; [] draws them solid
params.markStrokeWidthPixelsoutline width of filled marks — bars, sectors, boxes
params.cornerRadiusPixelsrounding of every rectangular mark; unset keeps per-mark defaults
params.fillOpacityFractionopacity of every filled mark; unset keeps per-mark defaults
params.positiveColorColorValuegrowth: candlesticks, OHLC bars
params.negativeColorColorValuedecline: candlesticks, OHLC bars, falling waterfall columns
axis.linebooleanthe axis line
axis.tickbooleantick marks
axis.gridLinebooleangrid lines (and the polar web)
axis.strokeWidthPixelsthickness of the line, the ticks and the grid
axis.gridDashPixels[]grid dash pattern; [] draws a solid line
axis.lineDashPixels[]dash pattern of the axis line itself; solid by default
axis.colorColorValuethe axis line alone; defaults to params.axisColor
axis.gridColorColorValuethe grid alone; defaults to params.axisColor
axis.tickColorColorValuethe ticks alone; defaults to params.axisColor
axis.tickSizePixelstick length (6)
axis.labelColorColorValuetick labels; defaults to params.mutedColor
axis.labelSizePixelstick label size; defaults to params.fontSize
axis.labelSpacingPixelsgap between the axis line and its labels (8)
axis.titleColorColorValueaxis title; defaults to params.foregroundColor
axis.titleSizePixelsaxis title size; one step above params.fontSize
overrides.commonRecord<string, unknown>chart-level blocks for all charts
overrides.<seriesType>.seriesRecord<string, unknown>defaults for series of a specific type