---
title: "Chart colour"
description: "Sequential, diverging and qualitative ramps, why the category and status palettes are both unavailable to a chart series, and how a reference band is coloured."
url: "https://opsinjs.pensievelabs.org/foundations/data-visualisation/chart-colour"
source: "https://opsinjs.pensievelabs.org/foundations/data-visualisation/chart-colour.md"
section: "Foundations"
kind: "foundation"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["chart palette", "sequential ramp", "diverging ramp", "qualitative ramp"]
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> Nothing is missing from this page. The data simply does not live in
> the prose.

<PageTemplate kind="foundation" />

## Overview [#overview]

opsinjs already has two colour palettes, and the obvious move is to reuse one for
charts. Both are wrong, for different reasons, and stating why is the whole point
of this page.

**The category palette is wrong because it is tuned for the wrong job.** Category
colours identify a part of someone's health such as sleep or activity, and they
are deliberately low in chroma so they can be used as large surfaces and as
backgrounds behind text without losing the contrast floor. Low-chroma colours are
exactly what you must not use as thin lines that need to be told apart from each
other. Two category accents that read as clearly different filling a card are
nearly identical as one-pixel strokes.

**The status palette is wrong because it means something.** Status colour says
*how much attention this needs*. A red line in a chart means "series three". Draw
a series in the urgent colour and you have made a clinical assertion about an
entire dataset by accident, and simultaneously spent the one colour the system
reserves for genuine escalation. See
[Alarm fatigue](../../health/alarm-fatigue.mdx).

So charts get a **third ramp**, generated for discriminability rather than for
identity or for urgency, and neither of the other two may appear on a mark.

<NotBuiltYet what="The third ramp">
  Nothing emits a chart ramp. `tokens/color.json` carries the neutral ramp, the six
  category ramps and the status stems, and no chart ramp, so not one
  `--opsin-chart-*` custom property exists in `app/tokens.generated.css`.
  `trend-sparkline` takes its stroke from the category axis at the
  `-line` role, which is the interim answer and not the one specified below.
</NotBuiltYet>

## How it works [#how-it-works]

**Three kinds of ramp, and choosing the wrong kind is a bigger error than
choosing the wrong hue.**

*Sequential* encodes an ordered quantity with no meaningful midpoint. A count, a
duration and an intensity are all quantities of that kind. Lightness does the
work; hue barely changes. A sequential ramp read in greyscale must still be
ordered, which is the test that catches most bad ones.

*Diverging* encodes an ordered quantity around a midpoint **that means something**.
It has two hues meeting at a neutral centre and it makes a strong claim: that the
centre is a real boundary. In health data that claim is almost always a clinical
one, so a diverging ramp may only be used where the midpoint has been set by the
product's clinical governance, and it must be labelled. A diverging ramp around
an arbitrary midpoint invents a threshold and draws everything on one side of it
as though it were a problem. The mean of the visible data is one such midpoint.

*Qualitative* distinguishes unordered series. This is the one most charts need,
it is the hardest to build, and it is where the category palette is most often
misused. It is generated by walking hue at a roughly constant lightness and
chroma, with each pair checked for perceptual distance and re-checked under
protanopia, deuteranopia and tritanopia simulation. The output is a fixed,
ordered list in which series one is always the same colour, because a palette
assigned in data order means the same series changes colour when the data
changes.

**Five series is the practical ceiling**, and it is a consequence of the above
rather than a preference: beyond about five, no set of hues stays reliably
distinguishable under all three CVD simulations at the thickness a chart line is
drawn at. A chart that needs more series needs small multiples, direct labelling,
or fewer series.

**Reference bands are neutral, not status-coloured.** The shaded region behind a
line showing the expected range is drawn as a neutral tint with a visible label
and a stated source. Colouring it green would assert that everything inside it is
fine, which is a diagnosis; the system does not make diagnoses, and "normal" is a
banned word for the same reason. See
[Reference ranges](../../health/reference-ranges.mdx).

## Using it [#using-it]

**Take the ramp that matches the data's structure, not the one that looks best.**
Sequential data in a qualitative palette loses its order. Unordered categories in
a sequential ramp acquire an order they do not have, and readers will believe it.

<DoDont>
  <DoDont.Do>
    Two metrics on one chart drawn in the first two qualitative chart colours,
    each labelled directly at its last point, with the reference band in a
    neutral tint behind them.
  </DoDont.Do>

  <DoDont.Dont>
    The same two metrics drawn in their category accents, with the band in the
    expected-status green. Two axes have been mixed, the category colours are too
    close to separate at that stroke weight, and the band has quietly told the
    reader that anything inside it is nothing to worry about.
  </DoDont.Dont>
</DoDont>

**Never colour a series by its current status.** A whole line coloured by the
latest reading applies today's judgement to every point in the window, including
the ones it does not describe.

**Do not use colour as the only difference between series.** Use direct labels
first, a second channel next, and colour third. The second channel is a dash
pattern, a marker shape or a thickness. The greyscale test is the fastest check:
print the chart without colour and see whether it still parses.

**Pin the assignment.** Series one takes chart colour one, deterministically, so
a chart does not recolour itself when the reader filters a series out.

**Do not tint the plot area.** A background wash behind a chart reduces every
mark's contrast at once and is the most common cause of a chart failing non-text
contrast while every individual colour passes in isolation.

## Tokens [#tokens]

The chart ramp is generated from `tokens/color.json` by
`scripts/build-tokens.mts`, and it carries sequential, diverging and qualitative
sets, each with its measured pairwise separation and its CVD simulation results
published alongside. It is emitted under its own prefix, distinct from both
`--opsin-category-*` and `--opsin-status-*`, so that a mark using the wrong
palette is visible in a stylesheet at a glance.

<NoDataYet script="scripts/build-tokens.mts" />

## Accessibility impact [#accessibility-impact]

* **The qualitative ramp is validated under three CVD simulations**, pairwise,
  and the results are published rather than asserted. `<CvdSimulator>` renders
  any of it under protanopia, deuteranopia, tritanopia and greyscale; the audit is
  on [Colour independence](../../accessibility/colour-independence.mdx).
* **Chart marks are non-text content and are measured as such.** WCAG 2.2 SC
  1.4.11 *Non-text Contrast* applies to every line, point and band against its
  background, and thin marks are the case where a nominally passing colour
  becomes invisible in practice.
* **Colour is never the only difference**, in a chart exactly as everywhere else
  in opsinjs. This is principle 2 from [Principles](../principles.mdx) applied to
  a surface where it is routinely abandoned.
* **A neutral reference band is an accessibility decision as well as a clinical
  one.** A coloured band read in greyscale is indistinguishable from a shaded
  region with no meaning; a labelled neutral one is not.
* **Dark mode is derived, not inverted.** A chart ramp inverted for dark mode
  reorders its own lightness and destroys a sequential ramp's ordering. The
  derivation rules are on
  [Colour → Dark mode](../colour/dark-mode.mdx).

## Related [#related]

* [The two colour axes](../../health/two-colour-axes.mdx) covers the two
  palettes this page is keeping out of charts, and why the separation matters.
* [Chart anatomy](./chart-anatomy.mdx) is the other half of a chart's honesty,
  and the bigger source of misreading.
* [Colour → Colour blindness](../colour/colour-blindness.mdx) has the redundancy
  requirement and the audit this ramp is checked against.
