---
title: "Colour"
description: "How opsinjs decides what colour anything is. Two independent axes, one derivation engine, and a contrast floor the build refuses to ship below."
url: "https://opsinjs.pensievelabs.org/foundations/colour"
source: "https://opsinjs.pensievelabs.org/foundations/colour.md"
section: "Foundations"
kind: "foundation"
reviewed: "2026-09-02"
reviewer: "design"
aliases: ["colour system", "color system", "palette", "oklch", "ramp", "hue", "chroma"]
---

> 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]

In most design systems colour is presentation. In a system whose reader is
looking at their own blood pressure, colour is an **assertion**. A red surface
behind a number says *something is wrong with you*. A green one says *you are
fine*. Neither of those sentences is one a component library is qualified to
say by accident, so opsinjs makes colour a decision rather than a palette you
pick from, and that decision carries a stated owner, a stated meaning, and a
measured floor.

Two things follow, and they are the whole of this section:

1. **Colour comes from exactly one of two axes.** *Category* says what kind of
   measurement this is. *Status* says how urgent it is. An element may take
   colour from one axis; never from both. The full argument, and the vocabulary
   for the four status levels, lives in
   [Two colour axes](../../health/two-colour-axes.mdx). That page is doctrine,
   and this section is the mechanism that implements it.
2. **Every value is derived, not chosen.** You give the engine one brand colour.
   It produces the ramps in OKLCH, clamps each step into the display's gamut,
   assigns the semantic roles, and then checks every pair it emitted against the
   published contrast floor. If a pair fails, the derivation fails loudly instead
   of shipping a theme that is pretty and unreadable.

The thing this is most often confused with is **theming**. Foundations tells you
what a token *means*; [Theming](../../theming/index.mdx) tells you how to
*change* it; [Reference](../../reference/index.mdx) is the generated list of
every one. If a paragraph here starts explaining how to override something, it
is in the wrong pillar.

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

### The two axes, in tokens [#the-two-axes-in-tokens]

Every colour token in opsinjs is named
`--opsin-<axis>-<name>-<role>`. The axis is `category` or `status`; there is no
third. The role is one of four, and only four:

| Role      | What it is                                               | Contrast obligation                                                                                                                                                                          |
| --------- | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `surface` | the fill behind content                                  | it is the *background* half of a measured pair                                                                                                                                               |
| `line`    | a stroke, a glyph, a chart line, a focus ring            | non-text contrast against the surface it sits on                                                                                                                                             |
| `ink`     | text and icons **on the matching surface**               | text contrast against that surface                                                                                                                                                           |
| `accent`  | an identity fill: a bar fill, a dial track, a legend dot | none, and that is the point. It is chosen for recognition, which is why it must be bounded by `line` or labelled in `ink` and may never carry text or be the only thing carrying the meaning |

So `--opsin-status-urgent-ink` is guaranteed legible on
`--opsin-status-urgent-surface`, and on nothing else. Pairs are the unit of
guarantee; [Colour roles](./colour-roles.mdx) states that rule properly and
lists what it excludes.

The six categories are `sleep`, `heart`, `activity`, `nutrition`, `mind` and
`labs`. The four status levels are `steady`, `watch`, `attention` and `urgent`, in
that order, plus a fifth stem `unknown` that is the absence of a verdict rather
than a fifth degree of one. The two axes are not told apart by chroma: both run
the same lightness ladder and the same chroma envelope, so at a given step a
category surface and a status surface measure Lc 0.00 and WCAG 1.00 against each
other, which is a difference of nothing. They are told apart by role instead. A
category paints ink, a line or an accent and never tints a surface, while a
status owns the tinted surface, so a card is only ever tinted by a verdict. That
asymmetry, role rather than chroma, is the single most important thing about the
palette, and [Colour roles](./colour-roles.mdx) states it in full.

A second asymmetry catches people in code rather than in prose: the bare Tailwind
name resolves to a different role on each axis. `bg-status-urgent` is the **line**;
`bg-category-heart` is the **accent**. Every other role is spelt out.

<Callout title="The token id and the word on screen are two different strings">
  `steady`, `watch`, `attention` and `urgent` are the names of the ramps. The word
  a reader sees comes from `CLINICAL_STATUS_META` in `lib/status.ts`, and it is
  close to the id without being it: `attention` renders as "Needs attention", and
  `unknown` renders as "Not known". The word is fixed; the sentence around it is
  the content decision, made in
  [Writing status and alerts](../../content/writing-status-and-alerts.mdx) and
  governed by
  [Clinical status semantics](../../health/clinical-status-semantics.mdx). Do not
  put a raw token id in front of a patient.
</Callout>

### The derivation engine, in one line [#the-derivation-engine-in-one-line]

Brand colour → OKLCH → a fixed lightness ladder → per-step chroma clamp to the
sRGB gamut → Display-P3 chroma escalation behind a media query → role
assignment → APCA validation loop → emitted CSS custom properties.

Each of those stages has a failure mode worth knowing about, which is why
[How the engine works](./how-the-engine-works.mdx) is a page of its own rather
than a diagram here.

### Where the values live [#where-the-values-live]

Authored input is `tokens/color.json`. `scripts/build-tokens.mts` reads it and
writes `app/tokens.generated.css`, which `app/globals.css` imports at a fixed
position. Until that script has run, the authored fallback block in
`globals.css` is what renders. A clean clone therefore looks correct rather than
unstyled, and `pnpm check:generated` fails the build if the two ever disagree.

No colour value on this site is typed into MDX. If you see one, it is inside a
fenced code block, illustrating the *shape* of a declaration.

## Using it [#using-it]

**Pick the axis before you pick the colour.** The question is never "what colour
should this card be". It is "is this card identifying a kind of measurement, or
delivering a verdict about one". Category and status answer different questions
and a surface that tries to answer both answers neither.

**Reach for a role, not a step.** Product code uses
`--opsin-status-attention-surface`, never a ramp step and never a raw `oklch()`. The
step numbers exist so the engine can talk about itself; they are not an API.
The `opsinjs` ESLint and Stylelint rules described in the
[Handbook](../../handbook/index.mdx) exist to catch the raw value before review
does.

**Do not add a status level.** Four is a design decision with a clinical
argument behind it, not a starting point. If you need a fifth, what you almost
certainly need is a different component. See
[Alarm fatigue](../../health/alarm-fatigue.mdx).

**Adding a category is fine.** Categories are identity, and a product that
measures something we did not anticipate should say so.
[Category palettes](../../theming/category-palettes.mdx) is the supported
recipe, and it is supported precisely because a new category cannot contaminate
the status axis.

**Colour is never the only carrier.** A status always ships with a word, and
with an icon where the layout allows one. This is not a nicety for a minority of
readers; it is what makes the system survive greyscale printing, direct
sunlight, a cheap screen and a photocopier. See
[Colour blindness](./colour-blindness.mdx).

## Tokens [#tokens]

<TokenTable scope="color" />

## Accessibility impact [#accessibility-impact]

Colour is the foundation with the largest accessibility surface in the system,
and it carries three distinct obligations that people routinely collapse into
one:

* **Contrast (WCAG 2.2 SC 1.4.3 and 1.4.11).** Every emitted pair is measured in
  both themes by `scripts/check-contrast.mts` and published in
  [Contrast and APCA](./contrast-and-apca.mdx). WCAG 2.2 is the conformance
  obligation; APCA is the tool the engine optimises against. Both are printed,
  side by side, because they disagree in ways a designer should be able to see.
* **Use of colour (SC 1.4.1).** No opsinjs component may express state through
  colour alone. That is a structural rule enforced at the component level, not a
  guideline. [Colour independence](../../accessibility/colour-independence.mdx)
  is where that rule is written down.
* **Adaptation.** `prefers-contrast: more`, `forced-colors: active` and dark mode
  are three different requests and the engine owes each a different answer. Dark
  mode is the one that is fully shipped: it is a re-derivation, not an inversion
  ([Dark mode](./dark-mode.mdx)). `prefers-contrast: more` is honoured in exactly
  one place and nowhere else. That place is `Surface`, which drops its
  translucent layer under `contrast-more:`. `forced-colors: active` is partly
  answered: `Surface` draws its edge as an outline, which forced colours keeps
  and recolours to `CanvasText`, and it stops rendering the backdrop. The scrim's
  dimming is still lost, because the system palette replaces the tint with
  `Canvas`, and no forced-colours capture exists yet.
  See [Increased contrast](../../accessibility/increased-contrast.mdx).

The failure mode that motivates all of this is specific: a reader with a
red-green colour vision deficiency, on a phone at 40% brightness outdoors,
glancing at a result. If the only difference between "Steady" and "Needs
attention" is hue, that reader gets the wrong answer and acts on it. Those two
are precisely the pair the generated audit finds indistinguishable under
deuteranopia.

## Related [#related]

* [Two colour axes](../../health/two-colour-axes.mdx) is the doctrine this
  section implements, and the argument for why an element may take colour from
  only one axis.
* [Materials](../materials/index.mdx) is a separate foundation. Colour decides
  what a surface is; the material ladder decides what is behind it, and the two
  multiply.
* [Theme generator](../../theming/theme-generator.mdx) is the same engine,
  wired to an input box, with the APCA verdict shown as you type.
