---
title: "The two colour axes"
description: "Category identity and clinical status are two independent colour channels. No colour value may carry both, and no element may be tinted by both axes at once."
url: "https://opsinjs.pensievelabs.org/health/two-colour-axes"
source: "https://opsinjs.pensievelabs.org/health/two-colour-axes.md"
section: "Health"
kind: "health"
evidence: "mixed"
reviewed: "2026-09-02"
reviewer: "design"
aliases: ["two axes", "category vs status", "colour axes", "never mix colour"]
implements: ["status-pill", "alert-banner", "range-bar", "result-card", "care-card", "metric-tile", "score-dial", "trend-sparkline", "range-legend", "body-map", "goal-ring"]
---

> 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="health" />

## What this means [#what-this-means]

In a typical consumer health app, a single hue carries several unrelated
meanings at once. Red marks the heart section, red marks a reading that is
"high", and red is the brand accent on the primary button. Blue marks sleep, the
informational tooltip and the disabled state on the same screen. By the fourth
screen the palette has stopped carrying information, and the reader has
correctly learnt that colour in this product is decoration. That lesson is hard
to reverse and is learnt long before it costs anything, so the cost falls due on
the day the app needs to say "this is not like your other readings" and has no
unused signal left to say it with.

The cause is structural rather than inattentive. Two different jobs are being
served by one palette. **Identity** helps someone recognise at a glance that one
tile is about sleep and another is about their heart. **Status** tells them
whether the number in front of them is fine, worth watching, or needs attention.
Both are legitimate uses of colour, and they cannot share a hue.

opsinjs separates the two into two axes with two palettes and two token
namespaces, and the rule against mixing them is specified for machine
enforcement by a lint rule that has not yet been written.

## The rule [#the-rule]

**Colour in opsinjs runs on exactly two independent axes. A single colour value
may express identity or status, never both, and no element may be tinted by both
axes at once.**

**Axis 1. Category identity.** Six families each carry a stable hue that says
what the metric is about and says nothing else about it. Their tokens live under
`--opsin-category-*`:

| Category  | Token stem                     | What it identifies                                               |
| --------- | ------------------------------ | ---------------------------------------------------------------- |
| Heart     | `--opsin-category-heart-*`     | Blood pressure, heart rate, rhythm, circulation                  |
| Activity  | `--opsin-category-activity-*`  | Movement, steps, exercise, energy expenditure                    |
| Sleep     | `--opsin-category-sleep-*`     | Duration, timing, quality, disturbance                           |
| Nutrition | `--opsin-category-nutrition-*` | Intake, hydration, glucose, weight                               |
| Mind      | `--opsin-category-mind-*`      | Mood, stress, cognition, wellbeing                               |
| Labs      | `--opsin-category-labs-*`      | Blood and other laboratory results reported by a testing service |

The four roles a category exposes are `-surface`, `-line`, `-ink` and `-accent`,
and there are no others, so the namespace holds no
`--opsin-category-heart-danger`: a token that carries a verdict about a reading
belongs to the status axis.

**Axis 2. Clinical status.** Four ordinal levels say how a reading sits against
what is expected for the person reading it, and say nothing about which body
system produced it. Their tokens live under `--opsin-status-*`, with the same
four roles:

`--opsin-status-steady-*` · `--opsin-status-watch-*` ·
`--opsin-status-attention-*` · `--opsin-status-urgent-*`

A fifth stem, `--opsin-status-unknown-*`, exists and is deliberately **not** a
fifth level. `unknown` is the absence of an assertion, which covers a reading
never taken, a stale reading, or a reading whose reference range the product
does not own. Colouring it anywhere near `watch` would let a reader take "we
do not know" for "probably fine".

### The four roles and their Tailwind classes [#the-four-roles-and-their-tailwind-classes]

Both axes use the same four role names, and they disagree about which role a
bare Tailwind class resolves to. The asymmetry follows from what each axis
usually paints: a status is a boundary, an icon stroke or a badge edge, and a
category is a fill. The two bare class names are therefore symmetrical in form
and not in what they resolve to. The contrast obligations in the fourth column
are set by
[Colour roles](../foundations/colour/colour-roles.mdx), which is canonical for
them.

| Role       | What it paints                                           | Bare Tailwind class resolves to it                                        | Contrast obligation                                                                                                  | May carry text                                                                                            |
| ---------- | -------------------------------------------------------- | ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `-surface` | The ground behind content, on a tile, a pill or a banner | No, spelt out as in `bg-status-attention-surface`                         | None of its own. It is the half that defines the pair                                                                | No. The pair is always `-ink` on `-surface`                                                               |
| `-line`    | A stroke, a border, a badge edge or a decorative glyph   | Yes, on the status axis: `bg-status-urgent` resolves to the **line**      | The non-text floor, against its own surface                                                                          | No, except an icon whose meaning is repeated in adjacent text. An icon that carries the meaning is `-ink` |
| `-ink`     | Text, and an icon that carries words                     | No, spelt out as in `text-status-attention-ink`                           | The text floor, against its own surface and against the page                                                         | Yes, and it is the only role that may                                                                     |
| `-accent`  | An identity fill or a chart mark                         | Yes, on the category axis: `bg-category-heart` resolves to the **accent** | None it must clear, because the role is chosen for recognition rather than for contrast. Both its pairs are advisory | No, and it is never the sole boundary either                                                              |

On each axis the other three roles are always spelt out in full. What the four
status levels mean, who may assign one, and what word the reader sees are on
[Clinical status semantics](./clinical-status-semantics.mdx).

### The never-mix rule [#the-never-mix-rule]

The rule decomposes into four statements, each checkable against a rendered
element:

1. An element painted from the category axis may not change colour when its
   status changes.
2. An element painted from the status axis may not change hue when its category
   changes.
3. No element resolves a colour from both namespaces for the same property. A
   card may have a category-tinted header and a status-tinted badge; the header
   may not be a blend of the two.
4. Status is never expressed by colour alone: every status carries a colour, an
   icon and a word together. A status therefore stays legible when both axes are
   rendered in greyscale, because the icon and the word survive the loss of hue.

<StatusAxisDemo />

## Why (evidence) [#why-evidence]

<ResearchNote evidence="mixed" date="2026-09-02">
  **The cited part.** Rule 4 restates WCAG 2.2 success criterion 1.4.1 (Use of
  Colour), which requires that colour is never the only visual means of
  conveying information, indicating an action or distinguishing a visual
  element. In a health interface, a "high" marker distinguished only by being
  red carries no information to a reader with a common form of colour vision
  deficiency. `scripts/check-contrast.mts` projects both axes through
  protanopia, deuteranopia, tritanopia and greyscale and lists every pair that
  collapses in the
  [Colour-vision audit](../reference/generated/contrast.mdx#colour-vision-audit),
  which the nightly job regenerates. How the check is built, and the forms of
  low vision it says nothing about, are on
  [Colour independence](../accessibility/colour-independence.mdx), which does not
  render the table itself.

  **The opinion part.** The separation into exactly two axes, and the choice of
  six categories, is our design judgement. We are not aware of a study comparing
  a two-axis palette against a merged one in a consumer health product, and we do
  not cite one. The argument is structural rather than empirical: a hue with a
  single meaning can be learnt once, whereas a hue with two meanings has to be
  resolved from context on every encounter. A reader who cannot predict what a
  hue means stops attending to colour at all.

  What would change our mind: evidence that users read a merged palette
  accurately in context, or that six identity hues is too many to distinguish
  reliably at tile size on a low-quality display. The second is the risk we
  consider more real, and it is why category colour is never load-bearing: a
  reader who cannot tell two category hues apart loses recognition speed and
  loses no meaning, because the category is also named in the words.
</ResearchNote>

<CvdSimulator>
  <StatusLadder />
</CvdSimulator>

The four status levels above must remain distinguishable under protanopia,
deuteranopia, tritanopia and full greyscale. Where a pair does collapse, the icon
and the word carry the status without help from the hue, which is the redundancy
rule 4 requires of every status rendering.

## Applying it [#applying-it]

<DoDont>
  <DoDont.Do>
    Paint identity on the container and status on a discrete indicator. A sleep
    tile keeps its sleep hue in every state; the `StatusPill` inside it changes
    from `steady` to `watch` without the tile changing colour.
  </DoDont.Do>

  <DoDont.Dont>
    Turn the sleep tile red when sleep is short. The category landmark then moves
    with the value, so the reader can no longer find the sleep tile by its hue,
    and an `urgent`-level signal is spent on a reading that asks nothing of the
    reader.
  </DoDont.Dont>
</DoDont>

<DoDont>
  <DoDont.Do>
    Use `--opsin-status-attention-surface` for the background of an alert about a
    heart reading. Status owns the alert; the heart category is named in the
    words.
  </DoDont.Do>

  <DoDont.Dont>
    Derive an "urgent heart" colour by blending `--opsin-category-heart-accent`
    with `--opsin-status-urgent-line`. Six categories against four status levels
    give twenty-four such blends, none of which appears in the token set
    `scripts/check-contrast.mts` measures, and each of which is a hue no reader
    has been taught to read.
  </DoDont.Dont>
</DoDont>

<DoDont>
  <DoDont.Do>
    Let a chart use the chart ramps from
    [Chart colour](../foundations/data-visualisation/chart-colour.mdx). Series
    colour is a third, separate problem with its own constraints.
  </DoDont.Do>

  <DoDont.Dont>
    Colour a chart's series by category token. Category hues are tuned to be
    distinguishable as identity marks at tile size, and separating adjacent lines
    in a sequential ramp is a different discrimination task.
  </DoDont.Dont>
</DoDont>

<DoDont>
  <DoDont.Do>
    Reach for `Callout` when the message is informational, such as a tip or a
    note about how the app works. `Callout` sits outside the status axis by
    design and exposes no `status`, `severity` or `color` prop.
  </DoDont.Do>

  <DoDont.Dont>
    Use `AlertBanner` for a non-clinical message because it is the component that
    looks important. A marketing prompt rendered at `attention` teaches the
    reader that an `attention` surface can be passed over, and the surface they
    most need to read arrives later wearing the same treatment;
    [Alarm fatigue](./alarm-fatigue.mdx) is where that budget is set.
  </DoDont.Dont>
</DoDont>

<ContrastReport scope="status" />

## Components that implement this [#components-that-implement-this]

{/* Generated from `implements`. Do not restate the list by hand. */}

The axis separation rests on three enforcement points. The token namespaces
`--opsin-category-*` and `--opsin-status-*` are disjoint, so a colour carrying
both axes has to be assembled by hand rather than resolved from a token. A
component that takes a `status` does not take a `category` for the same surface:
`StatusPill` is specified to read from the status axis exclusively, and a
category label is a separate element elsewhere on the screen. The third point, a
lint rule flagging any declaration that resolves both namespaces onto one
property, is specified at [Tooling](../handbook/tooling/index.mdx) and has not
been written, so until it exists a mixed axis on a hand-written surface is caught
in review or not at all.

## What this does not cover [#what-this-does-not-cover]

* **Which category a metric belongs to.** Some metrics sit in two: weight is
  nutrition and it is also cardiovascular. The implementing team picks one per
  surface and holds to it, and opsinjs does not adjudicate.
* **Adding a seventh category.** The mechanism is documented at
  [Category palettes](../theming/category-palettes.mdx). Adding one changes the
  identity vocabulary on every surface in the product, and the decision is owned
  by whoever owns the design system rather than by a theme.
* **Redefining the status palette.** opsinjs strongly discourages it, and
  [Status palettes](../theming/status-palettes.mdx) makes that argument first and
  then sets out how to change the ramp safely.
* **Brand colour.** A brand accent is a third namespace of its own, and it may
  never be a status colour. [Colour roles](../foundations/colour/colour-roles.mdx)
  defines where it sits.
* **The engine.** How the ramps are derived, clamped to gamut and validated
  against a contrast floor is covered by
  [Colour](../foundations/colour/index.mdx). This page governs what the colours
  are allowed to mean.

## Updates to this page [#updates-to-this-page]

<Reviewed />
