---
title: "Colour blindness"
description: "The status axis uses the worst possible hue family for colour vision deficiency, on purpose, and pays for it with a word, a shape and a generated audit."
url: "https://opsinjs.pensievelabs.org/foundations/colour/colour-blindness"
source: "https://opsinjs.pensievelabs.org/foundations/colour/colour-blindness.md"
section: "Foundations"
kind: "foundation"
reviewed: "2026-09-02"
reviewer: "design"
aliases: ["colour vision deficiency", "color blind", "deuteranopia", "protanopia", "tritanopia", "greyscale legibility", "red green"]
---

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

The four clinical status levels run green → amber → orange → red. Red-green is
the most common form of colour vision deficiency. We have, in other words,
chosen the single worst hue family available for the one axis in the system that
carries a verdict.

That was a deliberate decision and this page is the argument for it, together
with the three mechanisms that make it survivable. The short version: the
green-to-red mapping is what a reader has already learned from every other
health, transport and safety interface they have ever used, and unlearning it
costs more than it buys. So opsinjs keeps the mapping and removes the
*dependency* on it. Colour becomes one of three redundant carriers rather than
the carrier.

The thing this is most often confused with is *contrast*. They are unrelated
problems with unrelated fixes. A pair can clear every figure on
[Contrast and APCA](./contrast-and-apca.mdx) and still be two colours that a
particular reader cannot tell apart, because contrast is about luminance
difference and this page is about hue discrimination.

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

### What actually happens to our palette [#what-actually-happens-to-our-palette]

Under simulated protanopia and deuteranopia, the perceptual space collapses
roughly onto a blue-yellow axis. The consequences for the two axes are not
symmetric:

* **The status axis loses most of its separation.** Three of its four seeds
  already sit on one warm arc: `urgent` (hue 27), `attention` (hue 45) and
  `watch` (hue 82). The fourth seed, `steady` (hue 178), sits half a wheel away
  and still does not stay clear of them under simulation. The four levels do not
  become one colour, but the differences shrink to the point where a reader
  cannot rely on them at a glance, which is the only way anybody reads a status.
* **The category axis loses separation too, and it costs less.** Its six hues
  are spread right around the wheel at heart 15, nutrition 70, activity 140,
  labs 210, sleep 275 and mind 310. A wide spread turns out not to be immunity:
  the audit records category pairs collapsing under every condition it
  simulates. What differs is the consequence. A category is identity rather
  than a verdict, so a reader who confuses two of them loses a glance, not a
  decision.

Under tritanopia the failure moves rather than lifts: the blue-violet end suffers,
so `sleep` and `mind` converge, and the status axis does not get off either. The
audit records `steady` and `urgent` among the pairs collapsing there.

Under **greyscale** the status axis fails almost completely, and it is important
to say so rather than to claim a ladder that does not exist. The four status
surfaces do not merely sit close together in lightness; they are the same rung.
One lightness ladder serves every chromatic ramp, so that a given step means the
same lightness in a heart ramp and in an urgent one, and the role mapping pins
`surface` to step 50, `line` to 600 and `ink` to 800 in the light theme. Four
ramps, one rung each: desaturate them and the four surfaces are one grey, and so
are the four lines. The only status role whose lightness varies at all is
`accent`, because in light it is the design seed used verbatim. That variation
now descends in order, `steady` lightest through `watch` and `attention` to
`urgent` darkest, so the seed no longer contradicts the level it names. What the
order buys, in the light theme, is a ranking that survives desaturation: on a dial
band or a bar fill the eye weighs the accents in the same direction whether the hue
is present or stripped out, which was not true when `watch` sat lightest of the four.
In dark the seed reads as a hole on the slab, so `accent` is step 400, the way every
other role is a step there, and the four accents share one rung; desaturated they
rank in no direction at all, and the level is carried by the line, the word and the
icon, exactly as `surface` and `line` already are. It does
not buy separability. All four seeds fall inside a lightness band about a tenth
wide, which is the room the non-text contrast floor leaves on a near-white page,
so desaturated they crowd rather than spread, and two neighbouring accents can
still read as one grey. The order holds; the distance does not. Widening that
band into a fuller lightness ramp *as well* as a hue ramp would mean giving up
either the chroma the status axis needs or the contrast floor every accent has
to clear against the page.

The same shared ladder is why a category and a status can never share the tinted
surface. At a given step a heart surface and an urgent surface run the same
lightness and the same chroma, so they measure Lc 0.00 and WCAG 1.00 against each
other, a difference of nothing. Chroma cannot separate the two axes, so role
does: a category paints a line, a word or an accent and never a card, and only a
status owns a tinted surface, as [Colour roles](./colour-roles.mdx) sets out.

<Callout title="What “status must survive greyscale” means here">
  It means the *meaning* survives. A greyscale screenshot, a photocopy or a
  black-and-white print still tells you which level you are looking at. It does
  not mean the four greys are distinguishable, and we do not claim that they
  are. The meaning survives because the word and the icon are always present.
  [Colour independence](../../accessibility/colour-independence.mdx) states the
  same requirement from the accessibility side.
</Callout>

### The three redundant carriers [#the-three-redundant-carriers]

Every status in opsinjs carries all three. Not one of them is optional, and the
component-level rule is structural rather than advisory: a component that can
render a status without its word does not pass review.

1. **The word.** Always present, always adjacent, never a tooltip and never
   behind a hover. The token names `steady`, `watch`, `attention` and `urgent`
   are close to the words without being them: `attention` renders as "Needs
   attention", and the absence of a reading renders as "Not known". The rendered
   word is fixed in `lib/status.ts`; 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).
2. **The shape.** A distinct glyph per level, distinguishable by outline at
   16px and in a single colour. Not one glyph recoloured four times, which is
   the most common way this requirement is faked. The rule that an icon may
   never carry status on its own is in [Iconography](../iconography/index.mdx).
3. **The position or the arrangement.** A range with bands, a legend and a
   ladder all show several levels at once. Wherever a component does that, the
   levels are ordered, and the order is the same everywhere. A reader who cannot
   see the hues can still count.

### The audit [#the-audit]

The simulation is not a design-review nicety; it runs as a generated audit over
the whole declared pair set and is published rather than asserted.

<CvdSimulator>
  <StatusLadder />
</CvdSimulator>

<CvdSimulator>
  <StatusAxisDemo />
</CvdSimulator>

Toggle protanopia, deuteranopia, tritanopia and greyscale. The thing to watch
for is not "can I still tell the colours apart". You often cannot, and that is
expected. It is "can I still read the level", which you can, because the word is
there.

<ContrastReport scope="status" />

## Using it [#using-it]

**Never let colour be the only difference between two things a reader must
distinguish.** This is WCAG 2.2 SC 1.4.1 stated plainly, and it is the whole
rule. Everything below is an application of it.

<DoDont>
  <DoDont.Do>
    A status pill that reads "Needs attention", with its own glyph, on the
    `attention` surface. Remove the colour and it still says what it is. Remove
    the word and it does not. That is why the component will not render without
    it.
  </DoDont.Do>

  <DoDont.Dont>
    A coloured dot in a list of readings, with a key at the bottom of the screen
    explaining what each colour means. The reader has to hold four hue-to-meaning
    mappings in working memory, and the reader who cannot see the hues cannot use
    the key at all.
  </DoDont.Dont>
</DoDont>

<DoDont>
  <DoDont.Do>
    A trend line coloured by *category*, with direction shown by the line's shape
    and any verdict shown as a labelled band. Category colour is identity, so
    confusing two categories costs a reader a glance, not a decision.
  </DoDont.Do>

  <DoDont.Dont>
    A trend line that turns red where the values are out of range. The reader who
    cannot see red gets a line with no verdict on it, and there is no word
    anywhere to recover it from. Mark the region and label the mark.
  </DoDont.Dont>
</DoDont>

**Do not solve this with patterns alone.** Hatching and dot fills are a
legitimate redundancy for charts and a poor one for small UI, where they turn
into visual noise at the sizes health data is actually read at. If a chart needs
a non-colour channel, prefer direct labelling of the series over a texture and a
legend.

**Test with the simulator, then test without colour at all.** The strongest check
available to you costs nothing: take a screenshot, desaturate it fully, and read
the screen. If you cannot answer "what is this telling me", neither can a
substantial number of your users, and no amount of hue tuning will fix it.

## Tokens [#tokens]

<TokenTable scope="color" />

## Accessibility impact [#accessibility-impact]

**What is guaranteed.** No opsinjs component expresses a state, a verdict or a
category through colour alone; every status ships with a word and a distinct
glyph; the simulation audit runs over the full declared pair set and is
published with the theme it was run against.

**What is yours.** The sentence around the word, not the word. The status word
is fixed, because the component reads it from `lib/status.ts` and will not
render without it. What you own is therefore the measurement, the comparison and
the action beside it. We cannot guarantee those are meaningful, and "Alert" is
none of them. The same applies to any status you compute yourself from a
threshold we do not own. [Reference ranges](../../health/reference-ranges.mdx)
draws that boundary.

**What we do not attempt.** We do not ship a colour-blind-safe alternative
palette, and we do not offer a "colour blind mode" toggle. Both are common and
both are, in our view, worse: an alternative palette means two designs, only one
of which is tested, and a mode toggle asks a reader to self-identify to a health
app in order to read it. The redundancy is unconditional instead. Everybody
gets the word.

<ResearchNote evidence="opinion" date="2026-09-02">
  Keeping the conventional green-to-red mapping rather than substituting a
  blue-to-orange one is a judgement, not a finding. The argument for keeping it
  is transfer: the mapping is already learned, and a health product is a bad
  place to teach a new colour language. The argument against is that a
  blue-yellow axis survives the most common deficiencies far better.

  We chose transfer, and paid for it with unconditional redundancy. What would
  change our mind is evidence that readers with normal colour vision transfer
  just as well to a status axis running from blue to orange in a health context;
  we are not aware of such evidence, and we are not going to fabricate a study
  to justify a decision we made on judgement.
</ResearchNote>

## Related [#related]

* [Colour independence](../../accessibility/colour-independence.mdx) has the
  same requirement stated as an accessibility guarantee, with the generated
  audit.
* [Two colour axes](../../health/two-colour-axes.mdx) explains why category and
  status are separate, and why only one of them is allowed to carry a verdict.
* [Data visualisation](../data-visualisation/index.mdx) covers charts, which
  have their own redundancy problem. Category colours are not chart colours.
