# 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.

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:

```ts
// 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:

```css
: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.

```tsx
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:

```tsx
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.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
