Skip to content

7 · Colour blindness

Roughly 1 in 12 men cannot separate red from green.

Not "finds it harder". For a deuteranope, red and green are the same colour. There is no lighting, no contrast ratio and no amount of saturation that changes it, because the information is gone before it reaches the brain.

Which means a red/green status pair — the most common convention in software — carries no information at all for about 8% of your male users.

Why, in three sentences

Human colour vision comes from three types of cone cell in the retina, tuned to long, medium and short wavelengths. What you perceive as hue is the ratio between their three responses, so colour is a three-dimensional signal.

Lose one cone type and it becomes two-dimensional. A whole axis of distinction disappears, and every colour that differed only along that axis collapses onto its neighbour.

Term Means
trichromat three working cone types — most people
anomalous trichromat three types, but one is shifted, so the axis is compressed rather than gone
dichromat two working types — one axis is genuinely absent
protanopia the long-wavelength ("red") cone is missing
deuteranopia the medium-wavelength ("green") cone is missing
tritanopia the short-wavelength ("blue") cone is missing — much rarer, and not sex-linked

The 1-in-12 figure covers all red–green deficiency. Most of those people are anomalous trichromats rather than full dichromats, so severity varies continuously. Dichromacy is the hardest case, not the common one.

Why it is mostly men: the genes for the long- and medium-wavelength cones sit on the X chromosome. Men have one X, so a faulty copy has no backup; women have two, so they usually do. That single fact is the whole explanation for the roughly 8% versus 0.5% split, and it is why "1 in 12 men" is the number worth remembering rather than a population average.

See it

Below are nilam's four status colours plus its brand, as four different readers see them.

normal vision

× !

Four colours, four meanings.

deuteranopia

× !

The last three are one colour. No hue assignment fixes it.

protanopia

× !

warn and ok collapse. danger survives.

tritanopia

× !

Every status pair holds — and this is the vision that constrains the brand hue.

Those are SVG feColorMatrix filters. Each one is a 3×3 matrix multiply on the pixel's channels, and the matrices are from Machado, Oliveira & Fernandes (2009) at severity 1.0 — the model the literature uses.

Two details that matter.

color-interpolation-filters="linearRGB". The matrices operate on light, so the browser has to gamma-decode before multiplying and re-encode afterwards. Chapter 2's curve again. Run the same matrix on raw bytes and the answer is wrong, in a way that looks plausible.

These are the same numbers src/colour.mjs uses. The browser and nilam's prover are running identical arithmetic on identical inputs, so the panels above are not an illustration of the assertion. They are the assertion, rendered.

Measuring "are these the same colour"

Contrast is the wrong instrument, and this is worth being precise about.

  • Two colours can have identical luminance and be perfectly distinct — a mid red and a mid green.
  • Two colours with very different luminance can collapse to the same hue under simulation.

A contrast ratio has nothing to say about either case, because it only sees light.

What you want is a distance in a perceptual space. nilam measures straight-line distance in OKLab:

// src/colour.mjs
distance(a, b) = ( ΔL² + Δa² + Δb² )

Plain Euclidean, which is defensible precisely because OKLab is roughly uniform — no CIEDE2000 weighting needed, and none of its discontinuities either.

The threshold is 0.09. Below that, two colours read as the same. And it is worth saying plainly: 0.09 is a chosen number, not a published one. It was picked because it separates the pairs that visibly separate from the pairs that visibly do not. There is no standard to cite for it.

The measured collapses

nilam simulates all three dichromacies in both modes and reports the result rather than hiding it. Bold is a collapse:

light mode danger / warn danger / ok warn / ok
normal 0.1720 0.3614 0.2054
protanopia 0.1589 0.2098 0.0518
deuteranopia 0.0641 0.0255 0.0386
tritanopia 0.1257 0.3588 0.2447

Deuteranopia takes all three pairs. danger against ok — red against green — comes out at 0.0255, which is a quarter of the floor. The first run of the solver searched 15,360 hue combinations and the best it managed on that pair was 0.065.

So it cannot be fixed by choosing better colours. Danger is red and ok is green; that is what those words mean. Lowering the floor until it passed would be a lie.

"Don't rely on colour alone"

This is WCAG 1.4.1, and it has said so since 2008. Every design system repeats it. Almost none of them enforce it, and the reason is structural: there was no way to check it. You cannot write a rule that says "this red means something" without knowing which colours actually collide.

The collapse list above is what makes it checkable. Once you know that danger, warn and ok are one colour to a deuteranope, "those three components must carry a second channel" stops being advice and becomes a requirement you can fail a build on.

colour only

failed · expiring · active

colour only, as a deuteranope sees it

three identical dots

colour plus a glyph plus a word

×Failed !Expiring Active

and the same three, as a deuteranope sees them

×Failed !Expiring Active

The bottom row is still three identical colours. The glyph and the word are the entire difference, and that is the point — the palette has not been rescued, the component has.

The rule reaches further than badges. In nilam's combobox, .n-option[data-current] gets an inset bar as well as a background — because the difference between --neutral-3 and --neutral-4 is one contrast step by construction, the ramp being solved, and one small step cannot carry "this is the cursor" on its own.

The finding nobody was looking for

Here is what happens when you choose semantic hues by search instead of by taste, and then sweep the brand hue across all 360 degrees against a red/amber/green status set.

Only roughly 285–315 clears the 0.09 floor under every vision. Every blue collapses with ok for a tritanope.

The reason is structural, not a threshold artefact:

Tritanopia removes blue–yellow discrimination. A blue brand at 240–270 loses its blue component and drifts into the grey-green that ok has to occupy. A violet brand at 285 or above keeps a red component, so under the same simulation it moves toward pink and stays clear of green.

So: if your statuses are red, amber and green, your brand hue is not distinguishable from ok — not for a tritanope. Only violet brand hues survive that set.

Blue is the commonest brand colour in software. Which makes what nilam does about it the more interesting half of the story, and it is the subject of the next section.

nilam's signature is 285, and 285 is the lowest hue that clears the floor under every vision. That is an independent argument for a colour that had already been chosen by eye, years earlier, for entirely different reasons.

The finding that changed the rule

The obvious response to the sweep is to refuse: no palette for a blue brand. nilam did that for three releases, and 0.4.0 reversed it, on an argument worth reading because it is about prevalence rather than about colour.

collapse who it reaches old treatment
red vs green — danger/ok under deuteranopia roughly 1 in 12 men reported, glyph required
blue vs green — brand/ok under tritanopia roughly 1 in 10,000 refused to build

The prover was strict about the rare collapse and pragmatic about the one roughly 800 times more common. And the strictness bought nothing for anybody: npx nilam 250, an unremarkable blue, emitted no palette at all. Refusing does not produce a better brand hue, because a brand colour usually predates the palette by years and is not the palette author's to change. It produces a tool nobody uses.

The remedy that actually reaches a tritanope is the one red/green already gets: a second, non-hue channel on the affected component. A colour they cannot distinguish was never going to help them; a tick on the badge does.

So under a dichromacy the collapse is now measured, reported, and handed to proveStatusChannels() — which still fails the build if one of those components is hue-only. Nothing is weakened. The obligation moves from the palette, where it could not be discharged, to the component, where it can.

Under normal vision it is still a hard failure. If a save button and an error state are the same colour to everyone, no glyph makes that acceptable and the hue simply has to move. That rules out roughly 15–150, where the brand would sit on top of danger or warn.

# emits, reports the collapse, requires a glyph
npx nilam 250

# refuses instead, if the hue is still free to move
npx nilam 250 --strict-brand-hue

And why info has no hue at all

The same constraint, found earlier and from the other direction.

info was originally given a window of 215–275 — blue, as every design system does. With a violet brand at 285, the best assignment the solver could find left brand and info 0.052 apart under deuteranopia. The same colour, to that reader. There was no blue available that cleared the floor, because the brand had already taken that region of hue space.

The brand hue constrains which semantic hues remain available.

So info is built from the neutral scale — byte for byte identical to it. It is the state that means "nothing is wrong", it does not need to shout, and spending a hue on it buys a collision with the brand. It keeps the identical twelve-step shape and the identical floors: --info-11 is still 4.5:1 text and --info-7 is still a 3:1 border. Only the chroma is gone.

How nilam handles it

Three rules, and they differ in kind:

Rule If it fails
Hard the brand separates from every status under normal vision build fails — no glyph makes a universal collision acceptable
Hard every status pair separates under normal vision build fails
Measured which pairs — status/status and brand/status — collapse under each dichromacy reported, and it becomes a requirement on components

--strict-brand-hue moves the brand's dichromacy row back into the hard set, for when the hue genuinely is still free and you would rather be told to move it than take on a glyph obligation.

The tightest cell in the whole search is brand against ok, under tritanopia, in dark mode, at 0.0924 against a floor of 0.09. That is still the number the hue search maximises, and it is why ok sits at 142 rather than anywhere else in its window — so the default palette clears the floor everywhere, and the reporting path exists for the hues that do not.

The third rule is enforced, not suggested:

import { solvePalette, proveDichromacy, proveStatusChannels } from 'nilam';

const palette = solvePalette(285);
const collapses = [
  ...proveDichromacy('light', palette),
  ...proveDichromacy('dark', palette),
];

// What each status carries in YOUR components.
// Colour alone fails the build.
proveStatusChannels(collapses, {
  danger: ['colour', 'icon'],
  warn:   ['colour', 'icon'],
  ok:     ['colour', 'icon'],
});

Note what that call does not care about. It takes the collapse list and the channels, and it does not know or ask whether a given collapse came from red-versus-green or from a blue brand. Once the answer is "that component needs a second channel", the two cases are the same problem.

Where this stops

Stated as firmly as the claim:

  • The matrices are a model. A good one. Not an eye.
  • Only severity 1.0 is simulated. Real colour vision varies continuously, and most people with a deficiency are anomalous trichromats. Severity 1.0 is the hardest case, not the common one.
  • 0.09 is chosen. There is no standard behind it.
  • A prover measures separation, not appropriateness. It will happily certify a palette in which danger is magenta, and chapter 12 is about exactly that.

The claim, stated narrowly: no prior art was found for a machine-checked dichromat-separation assertion in a design system. That is all. It is not a claim that the palette is legible to everyone, or that the simulation is reality, or that colour blindness is solved.

Next: type and space — the half of a design system that cannot be solved, and what to do instead.

Reference: Colour blindness has every measured number, both modes, and the full argument. Honest limits has the boundaries.