Theming Charts and Canvas Colours at Runtime
Part of Runtime Theme Switching. A theme swap repaints every element that reads a custom property. A canvas is not one of them: its pixels were drawn once, from values that were resolved once, and nothing about changing an attribute redraws them.
Prerequisites Link to this section
- A theme applied as an attribute on the root element, with the resolver already in place.
- A chart or visualisation rendered to canvas, WebGL, or by a library that takes colours as configuration.
- Semantic tokens for the chart’s colours — series colours, axis, gridline and label — declared alongside the rest of the design system rather than inside the chart component.
- A redraw path in the renderer that can be called with new options without recreating the whole instance, if the library supports one.
Step-by-Step Implementation Link to this section
Step 1 — Declare chart tokens as first-class semantic roles Link to this section
Intent: the chart’s palette belongs in the token system, not in a configuration object inside the chart component.
:root {
--ds-chart-axis: #475569;
--ds-chart-gridline: #e2e8f0;
--ds-chart-label: #0f172a;
--ds-chart-series-1: #2563eb;
--ds-chart-series-2: #7c3aed;
--ds-chart-series-3: #059669;
}
[data-theme="dark"] {
--ds-chart-axis: #cbd5e1;
--ds-chart-gridline: #334155;
--ds-chart-label: #f1f5f9;
--ds-chart-series-1: #60a5fa;
--ds-chart-series-2: #a78bfa;
--ds-chart-series-3: #34d399;
}
Why this works: putting the chart palette in the same place as every other token means it participates in the contrast gate, the drift check and the theme parity check automatically. A palette that lives inside the chart component is invisible to all of them, which is how a dark theme ends up with a series colour nobody can see against the background.
Step 2 — Resolve tokens to concrete values before drawing Link to this section
Intent: read the computed value once per draw and hand the renderer plain colour strings.
const CHART_TOKENS = [
'axis', 'gridline', 'label', 'series-1', 'series-2', 'series-3',
];
export function readChartPalette(el = document.documentElement) {
const styles = getComputedStyle(el);
return Object.fromEntries(CHART_TOKENS.map((name) => [
name, styles.getPropertyValue(`--ds-chart-${name}`).trim(),
]));
}
Why this works: getComputedStyle resolves the whole chain — the alias, the theme override, any brand override — and returns the final value the browser would have painted. Reading from document.documentElement gets the global palette; reading from the chart’s own container instead picks up any component-scoped or brand-scoped overrides in effect at that point in the tree, which is usually what you want for a themed product.
Step 3 — Redraw when the theme attribute changes Link to this section
Intent: observe the attribute rather than the media query, so the redraw follows the resolved theme.
export function onThemeChange(callback) {
const observer = new MutationObserver((records) => {
if (records.some((r) => r.attributeName === 'data-theme')) callback();
});
observer.observe(document.documentElement, {
attributes: true, attributeFilter: ['data-theme'],
});
return () => observer.disconnect();
}
// Usage
const stop = onThemeChange(() => {
chart.updateOptions({ colors: Object.values(readChartPalette()) });
});
Why this works: a MutationObserver filtered to one attribute is cheap and fires exactly once per theme change. Listening to the prefers-color-scheme media query instead would miss changes made by the in-page toggle, which is the common case; listening to both produces double redraws.
Step 4 — Redraw efficiently, not destructively Link to this section
Intent: a theme change should update colours, not rebuild the chart and lose its state.
function applyPalette(chart) {
const palette = readChartPalette(chart.container);
chart.setOptions({
colors: [palette['series-1'], palette['series-2'], palette['series-3']],
axis: { stroke: palette.axis, labelColor: palette.label },
grid: { stroke: palette.gridline },
}, { redraw: true, animate: false });
}
Why this works: most chart libraries distinguish between updating options and recreating the instance. Recreating loses zoom position, selection state, tooltip focus and any in-flight animation — all of which a visitor who toggled the theme mid-analysis would notice immediately. Passing animate: false matters too: animating a colour change on a chart with hundreds of marks is expensive and, for a theme swap, pointless.
Step 5 — Handle SVG-based charts differently Link to this section
Intent: a chart rendered as SVG in the DOM can read custom properties directly and needs no redraw at all.
// SVG output: reference the token, do not resolve it.
series.attr('fill', 'var(--ds-chart-series-1)');
// Canvas output: resolve it, because fillStyle takes a colour, not a var().
ctx.fillStyle = readChartPalette()['series-1'];
Why this works: an SVG element in the document participates in the cascade like any other element, so fill: var(--ds-chart-series-1) re-resolves on a theme change for free. That is a strong argument for choosing SVG output where the data volume allows it — typically up to a few thousand marks, beyond which canvas wins on rendering cost.
Step 6 — Do not forget the export path Link to this section
Intent: a downloaded chart image should match the theme the visitor was looking at, or deliberately not.
function exportPng(chart, { theme = 'current' } = {}) {
if (theme === 'light') {
// Draw with the light palette regardless of the current theme.
const saved = chart.getOptions().colors;
applyPaletteFrom(chart, lightPalette);
const url = chart.toDataURL('image/png');
chart.setOptions({ colors: saved }, { redraw: true, animate: false });
return url;
}
return chart.toDataURL('image/png');
}
Why this works: an exported chart usually ends up in a document or a slide with a light background, so a dark-themed export is often the wrong artefact even though it matches what was on screen. Offering both — and defaulting to light for export while matching the screen for copy-to-clipboard — covers the two cases without a settings dialogue.
Verification Link to this section
Toggle the theme with a chart on screen and check three things: the series colours change, the axis and label colours change with them, and no state was lost — the zoom level, any selection, and the tooltip if one was open.
Then check the case that is easy to miss: load the page with the dark theme already active. A chart that reads its palette in a constructor which runs before the resolver has set the attribute will draw with light colours and never correct itself, because no mutation ever occurs. Reading the palette at draw time rather than at construction time is what prevents this, and the test for it is simply a reload rather than a toggle.
Troubleshooting Link to this section
| Symptom | Likely cause | Fix |
|---|---|---|
| The chart keeps its light colours after a toggle | No redraw is triggered; the canvas is a bitmap | Observe data-theme and update options on change |
| Colours are empty strings | The token name is misspelled, or the property is not defined at that element | getPropertyValue returns '' for unknown properties — assert non-empty |
| The chart is correct on toggle but wrong on reload | The palette was read in a constructor that ran before the resolver | Read at draw time |
| Series are indistinguishable in dark mode | The dark palette was derived by lightening, collapsing hue separation | Design the dark series palette deliberately; check pairwise difference, not only contrast against the background |
| Redraw loses the zoom level | The chart is being recreated rather than updated | Use the library’s option-update path |
Migration Note Link to this section
Retrofitting an existing chart component takes about an hour and is worth doing before adding a dark theme rather than after. The order matters: extracting the palette into tokens first means the dark values can be designed alongside the rest of the dark theme, where the contrast gate will check them.
Doing it the other way round — shipping the dark theme and then discovering the charts — produces a rushed dark palette chosen to look acceptable rather than to satisfy any constraint, and it is rarely revisited.
One decision worth making explicitly during the migration: whether charts should follow the theme at all. Some products deliberately render data visualisations on a permanently light surface, because the series palettes are established, printed materials match them, and readers compare screen output to those materials. That is a legitimate choice, and it is much better made deliberately than arrived at because nobody got around to theming the charts.
Designing a Dark Series Palette Link to this section
A dark series palette is not the light one lightened, and treating it that way is the most common reason charts read badly in dark mode.
Two constraints apply simultaneously and pull in different directions. Each series must have enough contrast against the dark background to be visible, which pushes every colour toward the light end. And each series must remain distinguishable from every other series, which needs hue and chroma separation that the same lightening tends to compress — as colours approach white, they converge.
The practical approach is to fix lightness and vary hue. Pick a single lightness step for the series band, high enough to clear the background comfortably, and distribute the series around the hue circle at that lightness. That produces a set with uniform prominence — no series looks more important than another by accident — and maximum separation for the lightness budget available.
Check the result two ways. Compute contrast against the background for each series, which is the accessibility floor. Then compute pairwise difference between every pair of series, which is the distinguishability requirement that no contrast check covers. A palette where two series differ by less than a noticeable step is a palette where a reader cannot tell two lines apart, regardless of how well each one contrasts with the background.
Finally, remember that colour is rarely the only channel available. Dashed and solid strokes, marker shapes and direct labelling all survive both themes and every form of colour vision deficiency, and a chart that uses them needs its palette to carry less of the load.
Related Link to this section
- Runtime Theme Switching — the attribute change this guide observes
- Theming SVG and Favicon Assets for Dark Mode — the neighbouring asset problem, including SVG that does follow automatically
- Colour Palette Architecture — where the chart series palette should be designed