Skip to content

A chart palette that survives dark mode

Six brand colours picked on white turn muddy or fluorescent on near-black. Define the series palette as tokens with two values each, assign them in order, and never let colour be the only encoding.

Kite5 min readships at docs/solutions/kite/a-chart-palette-that-survives-dark-mode.md

Tags: charts · dataviz · design-tokens · dark-mode · accessibility

The first chart goes in and looks fine. Then someone opens the dashboard in dark mode and the teal series has disappeared into the background, the amber one is glowing, and two of the five lines have become the same colour.

Chart colour is where a design system that only ever thought about surfaces and text falls over. Series colours have a different job: they must be distinguishable from each other, legible as a two-pixel line, and they must do both on two different backgrounds.

Rule one: the palette is tokens, not a config file

The most common mistake is an array of hexes in the chart config:

// wrong: invisible to the design system, fixed in both modes
const COLORS = ["#4f46e5", "#0d9488", "#d97706", "#db2777", "#0284c7"];

Make each series colour a token with a light value and a dark value, exactly like every other colour in the system:

:root {
  --chart-1: #4f46e5;  /* indigo  */
  --chart-2: #0d9488;  /* teal    */
  --chart-3: #d97706;  /* amber   */
  --chart-4: #db2777;  /* pink    */
  --chart-5: #0284c7;  /* sky     */
  --chart-6: #65a30d;  /* lime    */
}

:root.dark,
.dark {
  --chart-1: #818cf8;
  --chart-2: #2dd4bf;
  --chart-3: #fbbf24;
  --chart-4: #f472b6;
  --chart-5: #38bdf8;
  --chart-6: #a3e635;
}

Two things fall out. The chart library reads getComputedStyle(el).getPropertyValue("--chart-1"), or you pass "var(--chart-1)" straight through if it renders SVG, and the colours follow the mode for free. And the palette is now reviewable in one place instead of being scattered across every chart component.

Note the direction of the dark values: lighter and less saturated, roughly the 400 step of each hue rather than the 600. A colour that reads as a confident line on white reads as a smudge on near-black, and the fix is lightness, not more saturation.

Rule two: assign in order, forever

chart-1 is always the first series. Not "the most important one", not "the one that looked good": the first, in whatever stable order your data has.

const series = ["Signups", "Activations", "Paid"];
series.map((name, i) => <Line stroke={`var(--chart-${i + 1})`} key={name} />);

The reason is comparison across charts. If "Paid" is indigo in the funnel chart and pink in the retention chart, every reader pays a small tax on every glance, and nobody can articulate why the dashboard feels hard.

Stable order also means a series that disappears from one chart does not shift every colour after it. If your data can shrink, key the colour to the series identity rather than to its index:

const SERIES_COLOR: Record<string, string> = {
  signups: "var(--chart-1)",
  activations: "var(--chart-2)",
  paid: "var(--chart-3)",
};

Rule three: colour is never the only encoding

Roughly one in twelve men has some form of colour vision deficiency, and the common ones collapse red/green and, less often, blue/yellow. Six well-separated hues survive that better than six shades of one hue, but "survive" is not "legible".

So encode twice:

  • Label the series directly at the end of the line, or in a legend adjacent to the plot rather than in a corner.
  • Vary the mark where you can: solid and dashed strokes, different point shapes, different fill patterns on stacked areas.
  • Order the legend the way the series are ordered at the last data point, so a reader can match by position without matching by colour.

A quick check: screenshot the chart, desaturate it, and see whether you can still read it. If you cannot, colour was carrying the whole message.

Rule four: contrast against the plot area, not against white

A series line needs about 3:1 against the background it sits on to be reliably visible, the same floor as any other non-text UI boundary. Check it against the actual plot background in both modes, which is often a tinted surface rather than the page canvas.

Three related traps:

  • Gridlines want to be nearly invisible: the hairline token, not a series colour. A dark-mode gridline at light-mode opacity vanishes; use the token, and let the token carry the two values.
  • Area fills under a line should be the series colour at low alpha, produced from the token rather than picked by hand: color-mix(in oklab, var(--chart-1) 20%, transparent). Hand-picked pale fills drift out of the palette immediately.
  • Semantic colours are not series colours. Green-means-good and red-means-bad belong to success and error tokens. If one series happens to be "churn", resist making it red: the next chart will have two red things meaning different things.

Rule five: know what to do past six

Six is a real limit for categorical colour. Past it, humans stop matching legend to line, and no palette rescues that. The options are all structural rather than chromatic:

  • Group the tail. Top five plus "Other" is almost always more readable than eleven series.
  • Small multiples. Eleven tiny charts, one series each, share an axis and are read by position instead of by hue.
  • Highlight one, mute the rest. One series in --chart-1, everything else in the hairline colour, with a control to change which is highlighted.

If you genuinely need a seventh colour, add --chart-7 as a token with both values and document it, never as an inline hex in the one chart that needed it, which is how a palette becomes eleven colours nobody chose.

Verifying

Put a page in the app that renders one chart per type using the full palette, and look at it in both modes after any palette change. It takes ten minutes to build and it is the only way anyone will notice that --chart-3 and --chart-5 have converged.