opsinjs
FoundationsToken familiesColour

Colour blindness

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.

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

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 sets out.

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 states the same requirement from the accessibility side.

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 and governed by Clinical status semantics.
  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.
  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 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.

Colour vision simulation
  1. 1Steady

    This reading is where it is expected to be.

    Assigned by: The consuming product, from a reference range it owns.

  2. 2Watch

    This reading is outside your usual range. On its own that is not unusual, and there is nothing to do before your next reading.

    Assigned by: The consuming product, from a reference range it owns.

  3. 3Needs attention

    Contact your care team about this reading. It is outside the range they set for you.

    Assigned by: The consuming product, from a clinically reviewed threshold.

  4. 4Urgent

    Contact your urgent care service now. Tell them this reading and when you took it.

    Assigned by: A clinically reviewed threshold with a named clinical owner.

Simulated with SVG colour matrices. The design question is whether this can still be read without hue, and the simulation is close enough to answer that. It is not a clinical instrument. The audited version is lib/color/cvd.ts.

Colour vision simulation
Category only. Identity, no verdict.

Heart

148/92

Blood pressure, resting heart rate, rhythm

Simulated with SVG colour matrices. The design question is whether this can still be read without hue, and the simulation is close enough to answer that. It is not a clinical instrument. The audited version is lib/color/cvd.ts.

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.

PairThemeAPCA LcWCAG 2.2Floor
attention ink on attention surfacelight89.410.35:1Pass
attention ink on attention surfacedark-81.112.54:1Pass
attention line on attention surfacelight70.04.90:1Pass
attention line on attention surfacedark-65.59.54:1Pass
attention accent on attention surfacelight65.14.18:1Pass
attention accent on attention surfacedark-49.46.80:1Pass
attention ink on the attention bandlight22.42.48:1Below floor
attention ink on the attention banddark-31.01.84:1Below floor
attention accent on the pagelight67.84.35:1Pass
attention accent on the pagedark-50.57.47:1Pass
attention ink on the pagelight92.210.77:1Pass
attention ink on the pagedark-82.313.76:1Pass
steady ink on steady surfacelight88.39.62:1Pass
steady ink on steady surfacedark-82.912.56:1Pass
steady line on steady surfacelight67.54.45:1Pass
steady line on steady surfacedark-68.99.91:1Pass
steady accent on steady surfacelight56.43.21:1Pass
steady accent on steady surfacedark-53.27.25:1Pass
steady ink on the steady bandlight30.03.00:1Below floor
steady ink on the steady banddark-28.81.73:1Below floor
steady accent on the pagelight58.63.31:1Pass
steady accent on the pagedark-54.58.19:1Pass
steady ink on the pagelight90.69.94:1Pass
steady ink on the pagedark-84.214.20:1Pass
unknown ink on unknown surfacelight89.59.98:1Pass
unknown ink on unknown surfacedark-82.212.56:1Pass
unknown line on unknown surfacelight69.24.64:1Pass
unknown line on unknown surfacedark-67.29.72:1Pass
unknown accent on unknown surfacelight43.12.29:1Below floor
unknown accent on unknown surfacedark-51.37.05:1Pass
unknown accent on the pagelight45.62.37:1Below floor
unknown accent on the pagedark-52.37.84:1Pass
unknown ink on the pagelight92.010.34:1Pass
unknown ink on the pagedark-83.113.97:1Pass
urgent ink on urgent surfacelight88.210.58:1Pass
urgent ink on urgent surfacedark-80.312.52:1Pass
urgent line on urgent surfacelight70.05.09:1Pass
urgent line on urgent surfacedark-64.89.52:1Pass
urgent accent on urgent surfacelight72.35.58:1Pass
urgent accent on urgent surfacedark-48.06.63:1Pass
urgent ink on the urgent bandlight13.91.90:1Below floor
urgent ink on the urgent banddark-31.51.89:1Below floor
urgent accent on the pagelight75.35.84:1Pass
urgent accent on the pagedark-49.57.24:1Pass
urgent ink on the pagelight91.211.06:1Pass
urgent ink on the pagedark-81.813.66:1Pass
watch ink on watch surfacelight89.410.02:1Pass
watch ink on watch surfacedark-82.112.58:1Pass
watch line on watch surfacelight69.54.72:1Pass
watch line on watch surfacedark-66.89.68:1Pass
watch accent on watch surfacelight61.73.72:1Pass
watch accent on watch surfacedark-50.76.95:1Pass
watch ink on the watch bandlight25.72.70:1Below floor
watch ink on the watch banddark-30.61.81:1Below floor
watch accent on the pagelight64.23.85:1Pass
watch accent on the pagedark-51.77.71:1Pass
watch ink on the pagelight91.910.38:1Pass
watch ink on the pagedark-83.113.95:1Pass
attention surface on the card materiallight0.01.09:1Below floor
attention surface on the card materialdark0.01.00:1Below floor
steady surface on the card materiallight0.01.08:1Below floor
steady surface on the card materialdark0.01.03:1Below floor
urgent surface on the card materiallight0.01.09:1Below floor
urgent surface on the card materialdark0.01.01:1Below floor
watch surface on the card materiallight0.01.08:1Below floor
watch surface on the card materialdark0.01.01:1Below floor
the focus ring on the attention surfacelight67.34.44:1Pass
the focus ring on the attention surfacedark-45.96.30:1Pass
attention ink on the neutral hover filllight88.610.23:1Pass
attention ink on the neutral hover filldark-79.310.53:1Pass
the focus ring on the steady surfacelight67.84.47:1Pass
the focus ring on the steady surfacedark-45.86.12:1Pass
steady ink on the neutral hover filllight87.09.43:1Pass
steady ink on the neutral hover filldark-81.310.86:1Pass
the focus ring on the urgent surfacelight67.04.42:1Pass
the focus ring on the urgent surfacedark-45.56.34:1Pass
urgent ink on the neutral hover filllight87.710.50:1Pass
urgent ink on the neutral hover filldark-78.910.46:1Pass
the focus ring on the watch surfacelight67.64.46:1Pass
the focus ring on the watch surfacedark-46.06.24:1Pass
watch ink on the neutral hover filllight88.39.86:1Pass
watch ink on the neutral hover filldark-80.110.67:1Pass

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.

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.

Don’t

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.

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.

Don’t

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.

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

TokenWhat it controlsUsed by
--opsin-neutral-0oklch(1 0 0)Neutral ramp, step 0.no component
--opsin-neutral-50oklch(0.985 0.002 250)Neutral ramp, step 50.no component
--opsin-neutral-100oklch(0.967 0.003 250)Neutral ramp, step 100.no component
--opsin-neutral-200oklch(0.929 0.004 250)Neutral ramp, step 200.no component
--opsin-neutral-300oklch(0.871 0.005 250)Neutral ramp, step 300.no component
--opsin-neutral-400oklch(0.708 0.008 250)Neutral ramp, step 400.no component
--opsin-neutral-500oklch(0.556 0.009 250)Neutral ramp, step 500.no component
--opsin-neutral-600oklch(0.439 0.009 250)Neutral ramp, step 600.no component
--opsin-neutral-700oklch(0.371 0.008 250)Neutral ramp, step 700.no component
--opsin-neutral-800oklch(0.269 0.007 250)Neutral ramp, step 800.no component
--opsin-neutral-900oklch(0.205 0.006 250)Neutral ramp, step 900.no component
--opsin-neutral-950oklch(0.145 0.005 250)Neutral ramp, step 950.no component
--opsin-neutral-1000oklch(0 0 0)Neutral ramp, step 1000.no component
--opsin-chrome-backgroundoklch(0.985 0.002 250)Chrome role background, resolved to the ladder step it names.no component
--opsin-chrome-foregroundoklch(0.205 0.006 250)Chrome role foreground, resolved to the ladder step it names.no component
--opsin-chrome-cardoklch(1 0 0)Chrome role card, resolved to the ladder step it names.no component
--opsin-chrome-card-foregroundoklch(0.205 0.006 250)Chrome role cardForeground, resolved to the ladder step it names.no component
--opsin-chrome-mutedoklch(0.967 0.003 250)Chrome role muted, resolved to the ladder step it names.no component
--opsin-chrome-muted-foregroundoklch(0.439 0.009 250)Chrome role mutedForeground, resolved to the ladder step it names.no component
--opsin-chrome-borderoklch(0.556 0.009 250)Chrome role border, resolved to the ladder step it names.no component
--opsin-chrome-inputoklch(0.556 0.009 250)Chrome role input, resolved to the ladder step it names.no component
--opsin-chrome-ringoklch(0.55 0.13 250)Chrome role ring, resolved to the ladder step it names.no component
--opsin-chrome-primaryoklch(0.51 0.135 250)Chrome role primary, resolved to the ladder step it names.no component
--opsin-chrome-primary-foregroundoklch(0.99 0 0)Chrome role primaryForeground, resolved to the ladder step it names.no component
--opsin-category-sleep-50oklch(0.972 0.013 275)Sleep ramp, step 50.no component
--opsin-category-sleep-100oklch(0.941 0.028 275)Sleep ramp, step 100.no component
--opsin-category-sleep-200oklch(0.884 0.057 275)Sleep ramp, step 200.no component
--opsin-category-sleep-300oklch(0.806 0.092 275)Sleep ramp, step 300.no component
--opsin-category-sleep-400oklch(0.714 0.123 275)Sleep ramp, step 400.no component
--opsin-category-sleep-500oklch(0.622 0.14 275)Sleep ramp, step 500.no component
--opsin-category-sleep-600oklch(0.541 0.136 275)Sleep ramp, step 600.no component
--opsin-category-sleep-700oklch(0.452 0.123 275)Sleep ramp, step 700.no component
--opsin-category-sleep-800oklch(0.362 0.104 275)Sleep ramp, step 800.no component
--opsin-category-sleep-900oklch(0.276 0.081 275)Sleep ramp, step 900.no component
--opsin-category-sleep-950oklch(0.208 0.062 275)Sleep ramp, step 950.no component
--opsin-category-sleep-surfaceoklch(0.972 0.013 275)The tinted background a component in this ramp sits on.no component
--opsin-category-sleep-lineoklch(0.541 0.136 275)The boundary, icon stroke or chart mark. The lightest value in the ramp that clears the non-text floor against `surface`.trend-sparkline
--opsin-category-sleep-inkoklch(0.362 0.104 275)Text and text-sized icons on `surface`. Clears the text floor against `surface`.metric-tile, range-bar, result-card, score-dial
--opsin-category-sleep-accentoklch(0.58 0.14 275)The identity fill: a bar fill, a dial track, a legend dot. Chosen for recognition, not for contrast. It must be bounded by `line` or labelled in `ink`; it is never the only thing that carries the meaning.log-sheet
--opsin-category-heart-50oklch(0.972 0.014 15)Heart ramp, step 50.no component
--opsin-category-heart-100oklch(0.941 0.03 15)Heart ramp, step 100.no component
--opsin-category-heart-200oklch(0.884 0.062 15)Heart ramp, step 200.no component
--opsin-category-heart-300oklch(0.806 0.112 15)Heart ramp, step 300.no component
--opsin-category-heart-400oklch(0.714 0.15 15)Heart ramp, step 400.no component
--opsin-category-heart-500oklch(0.622 0.17 15)Heart ramp, step 500.no component
--opsin-category-heart-600oklch(0.541 0.165 15)Heart ramp, step 600.no component
--opsin-category-heart-700oklch(0.452 0.15 15)Heart ramp, step 700.no component
--opsin-category-heart-800oklch(0.362 0.126 15)Heart ramp, step 800.no component
--opsin-category-heart-900oklch(0.276 0.099 15)Heart ramp, step 900.no component
--opsin-category-heart-950oklch(0.208 0.075 15)Heart ramp, step 950.no component
--opsin-category-heart-surfaceoklch(0.972 0.014 15)The tinted background a component in this ramp sits on.no component
--opsin-category-heart-lineoklch(0.541 0.165 15)The boundary, icon stroke or chart mark. The lightest value in the ramp that clears the non-text floor against `surface`.trend-sparkline
--opsin-category-heart-inkoklch(0.362 0.126 15)Text and text-sized icons on `surface`. Clears the text floor against `surface`.metric-tile, range-bar, result-card, score-dial
--opsin-category-heart-accentoklch(0.62 0.17 15)The identity fill: a bar fill, a dial track, a legend dot. Chosen for recognition, not for contrast. It must be bounded by `line` or labelled in `ink`; it is never the only thing that carries the meaning.log-sheet
--opsin-category-activity-50oklch(0.972 0.022 140)Activity ramp, step 50.no component
--opsin-category-activity-100oklch(0.941 0.038 140)Activity ramp, step 100.no component
--opsin-category-activity-200oklch(0.884 0.067 140)Activity ramp, step 200.no component
--opsin-category-activity-300oklch(0.806 0.106 140)Activity ramp, step 300.no component
--opsin-category-activity-400oklch(0.714 0.141 140)Activity ramp, step 400.no component
--opsin-category-activity-500oklch(0.622 0.16 140)Activity ramp, step 500.no component
--opsin-category-activity-600oklch(0.541 0.155 140)Activity ramp, step 600.no component
--opsin-category-activity-700oklch(0.452 0.141 140)Activity ramp, step 700.no component
--opsin-category-activity-800oklch(0.362 0.118 140)Activity ramp, step 800.no component
--opsin-category-activity-900oklch(0.276 0.092 140)Activity ramp, step 900.no component
--opsin-category-activity-950oklch(0.208 0.07 140)Activity ramp, step 950.no component
--opsin-category-activity-surfaceoklch(0.972 0.022 140)The tinted background a component in this ramp sits on.no component
--opsin-category-activity-lineoklch(0.541 0.155 140)The boundary, icon stroke or chart mark. The lightest value in the ramp that clears the non-text floor against `surface`.trend-sparkline
--opsin-category-activity-inkoklch(0.362 0.118 140)Text and text-sized icons on `surface`. Clears the text floor against `surface`.metric-tile, range-bar, result-card, score-dial
--opsin-category-activity-accentoklch(0.7 0.16 140)The identity fill: a bar fill, a dial track, a legend dot. Chosen for recognition, not for contrast. It must be bounded by `line` or labelled in `ink`; it is never the only thing that carries the meaning.log-sheet
--opsin-category-nutrition-50oklch(0.972 0.02 70)Nutrition ramp, step 50.no component
--opsin-category-nutrition-100oklch(0.941 0.034 70)Nutrition ramp, step 100.no component
--opsin-category-nutrition-200oklch(0.884 0.059 70)Nutrition ramp, step 200.no component
--opsin-category-nutrition-300oklch(0.806 0.092 70)Nutrition ramp, step 300.no component
--opsin-category-nutrition-400oklch(0.714 0.123 70)Nutrition ramp, step 400.no component
--opsin-category-nutrition-500oklch(0.622 0.135 70)Nutrition ramp, step 500.no component
--opsin-category-nutrition-600oklch(0.541 0.118 70)Nutrition ramp, step 600.no component
--opsin-category-nutrition-700oklch(0.452 0.099 70)Nutrition ramp, step 700.no component
--opsin-category-nutrition-800oklch(0.362 0.08 70)Nutrition ramp, step 800.no component
--opsin-category-nutrition-900oklch(0.276 0.062 70)Nutrition ramp, step 900.no component
--opsin-category-nutrition-950oklch(0.208 0.049 70)Nutrition ramp, step 950.no component
--opsin-category-nutrition-surfaceoklch(0.972 0.02 70)The tinted background a component in this ramp sits on.no component
--opsin-category-nutrition-lineoklch(0.541 0.118 70)The boundary, icon stroke or chart mark. The lightest value in the ramp that clears the non-text floor against `surface`.trend-sparkline
--opsin-category-nutrition-inkoklch(0.362 0.08 70)Text and text-sized icons on `surface`. Clears the text floor against `surface`.metric-tile, range-bar, result-card, score-dial
--opsin-category-nutrition-accentoklch(0.75 0.14 70)The identity fill: a bar fill, a dial track, a legend dot. Chosen for recognition, not for contrast. It must be bounded by `line` or labelled in `ink`; it is never the only thing that carries the meaning.log-sheet
--opsin-category-mind-50oklch(0.972 0.018 310)Mind ramp, step 50.no component
--opsin-category-mind-100oklch(0.941 0.034 310)Mind ramp, step 100.no component
--opsin-category-mind-200oklch(0.884 0.059 310)Mind ramp, step 200.no component
--opsin-category-mind-300oklch(0.806 0.092 310)Mind ramp, step 300.no component
--opsin-category-mind-400oklch(0.714 0.123 310)Mind ramp, step 400.no component
--opsin-category-mind-500oklch(0.622 0.14 310)Mind ramp, step 500.no component
--opsin-category-mind-600oklch(0.541 0.136 310)Mind ramp, step 600.no component
--opsin-category-mind-700oklch(0.452 0.123 310)Mind ramp, step 700.no component
--opsin-category-mind-800oklch(0.362 0.104 310)Mind ramp, step 800.no component
--opsin-category-mind-900oklch(0.276 0.081 310)Mind ramp, step 900.no component
--opsin-category-mind-950oklch(0.208 0.062 310)Mind ramp, step 950.no component
--opsin-category-mind-surfaceoklch(0.972 0.018 310)The tinted background a component in this ramp sits on.no component
--opsin-category-mind-lineoklch(0.541 0.136 310)The boundary, icon stroke or chart mark. The lightest value in the ramp that clears the non-text floor against `surface`.trend-sparkline
--opsin-category-mind-inkoklch(0.362 0.104 310)Text and text-sized icons on `surface`. Clears the text floor against `surface`.metric-tile, range-bar, result-card, score-dial
--opsin-category-mind-accentoklch(0.62 0.14 310)The identity fill: a bar fill, a dial track, a legend dot. Chosen for recognition, not for contrast. It must be bounded by `line` or labelled in `ink`; it is never the only thing that carries the meaning.log-sheet
--opsin-category-labs-50oklch(0.972 0.015 210)Labs ramp, step 50.no component
--opsin-category-labs-100oklch(0.941 0.026 210)Labs ramp, step 100.no component
--opsin-category-labs-200oklch(0.884 0.046 210)Labs ramp, step 200.no component
--opsin-category-labs-300oklch(0.806 0.073 210)Labs ramp, step 300.no component
--opsin-category-labs-400oklch(0.714 0.097 210)Labs ramp, step 400.no component
--opsin-category-labs-500oklch(0.622 0.108 210)Labs ramp, step 500.no component
--opsin-category-labs-600oklch(0.541 0.094 210)Labs ramp, step 600.no component
--opsin-category-labs-700oklch(0.452 0.079 210)Labs ramp, step 700.no component
--opsin-category-labs-800oklch(0.362 0.063 210)Labs ramp, step 800.no component
--opsin-category-labs-900oklch(0.276 0.049 210)Labs ramp, step 900.no component
--opsin-category-labs-950oklch(0.208 0.038 210)Labs ramp, step 950.no component
--opsin-category-labs-surfaceoklch(0.972 0.015 210)The tinted background a component in this ramp sits on.no component
--opsin-category-labs-lineoklch(0.541 0.094 210)The boundary, icon stroke or chart mark. The lightest value in the ramp that clears the non-text floor against `surface`.trend-sparkline
--opsin-category-labs-inkoklch(0.362 0.063 210)Text and text-sized icons on `surface`. Clears the text floor against `surface`.metric-tile, range-bar, result-card, score-dial
--opsin-category-labs-accentoklch(0.6 0.11 210)The identity fill: a bar fill, a dial track, a legend dot. Chosen for recognition, not for contrast. It must be bounded by `line` or labelled in `ink`; it is never the only thing that carries the meaning.log-sheet
--opsin-status-steady-50oklch(0.972 0.015 178)Steady ramp, step 50.no component
--opsin-status-steady-100oklch(0.941 0.026 178)Steady ramp, step 100.no component
--opsin-status-steady-200oklch(0.884 0.046 178)Steady ramp, step 200.no component
--opsin-status-steady-300oklch(0.806 0.073 178)Steady ramp, step 300.no component
--opsin-status-steady-400oklch(0.714 0.097 178)Steady ramp, step 400.no component
--opsin-status-steady-500oklch(0.622 0.11 178)Steady ramp, step 500.no component
--opsin-status-steady-600oklch(0.541 0.1 178)Steady ramp, step 600.no component
--opsin-status-steady-700oklch(0.452 0.084 178)Steady ramp, step 700.no component
--opsin-status-steady-800oklch(0.362 0.067 178)Steady ramp, step 800.no component
--opsin-status-steady-900oklch(0.276 0.052 178)Steady ramp, step 900.no component
--opsin-status-steady-950oklch(0.208 0.041 178)Steady ramp, step 950.no component
--opsin-status-steady-surfaceoklch(0.972 0.015 178)The tinted background a component in this ramp sits on.alert-banner, status-pill
--opsin-status-steady-lineoklch(0.541 0.1 178)The boundary, icon stroke or chart mark. The lightest value in the ramp that clears the non-text floor against `surface`.alert-banner, range-bar, range-legend, status-pill
--opsin-status-steady-inkoklch(0.362 0.067 178)Text and text-sized icons on `surface`. Clears the text floor against `surface`.alert-banner, score-dial, status-pill
--opsin-status-steady-accentoklch(0.62 0.11 178)The identity fill: a bar fill, a dial track, a legend dot. Chosen for recognition, not for contrast. It must be bounded by `line` or labelled in `ink`; it is never the only thing that carries the meaning.score-dial
--opsin-status-watch-50oklch(0.972 0.02 82)Watch ramp, step 50.no component
--opsin-status-watch-100oklch(0.941 0.034 82)Watch ramp, step 100.no component
--opsin-status-watch-200oklch(0.884 0.059 82)Watch ramp, step 200.no component
--opsin-status-watch-300oklch(0.806 0.092 82)Watch ramp, step 300.no component
--opsin-status-watch-400oklch(0.714 0.123 82)Watch ramp, step 400.no component
--opsin-status-watch-500oklch(0.622 0.129 82)Watch ramp, step 500.no component
--opsin-status-watch-600oklch(0.541 0.112 82)Watch ramp, step 600.no component
--opsin-status-watch-700oklch(0.452 0.094 82)Watch ramp, step 700.no component
--opsin-status-watch-800oklch(0.362 0.076 82)Watch ramp, step 800.no component
--opsin-status-watch-900oklch(0.276 0.059 82)Watch ramp, step 900.no component
--opsin-status-watch-950oklch(0.208 0.046 82)Watch ramp, step 950.no component
--opsin-status-watch-surfaceoklch(0.972 0.02 82)The tinted background a component in this ramp sits on.alert-banner, status-pill
--opsin-status-watch-lineoklch(0.541 0.112 82)The boundary, icon stroke or chart mark. The lightest value in the ramp that clears the non-text floor against `surface`.alert-banner, range-bar, range-legend, status-pill
--opsin-status-watch-inkoklch(0.362 0.076 82)Text and text-sized icons on `surface`. Clears the text floor against `surface`.alert-banner, score-dial, status-pill
--opsin-status-watch-accentoklch(0.60 0.123 82)The identity fill: a bar fill, a dial track, a legend dot. Chosen for recognition, not for contrast. It must be bounded by `line` or labelled in `ink`; it is never the only thing that carries the meaning.score-dial
--opsin-status-attention-50oklch(0.972 0.015 45)Needs attention ramp, step 50.no component
--opsin-status-attention-100oklch(0.941 0.032 45)Needs attention ramp, step 100.no component
--opsin-status-attention-200oklch(0.884 0.063 45)Needs attention ramp, step 200.no component
--opsin-status-attention-300oklch(0.806 0.099 45)Needs attention ramp, step 300.no component
--opsin-status-attention-400oklch(0.714 0.132 45)Needs attention ramp, step 400.no component
--opsin-status-attention-500oklch(0.622 0.15 45)Needs attention ramp, step 500.no component
--opsin-status-attention-600oklch(0.541 0.145 45)Needs attention ramp, step 600.no component
--opsin-status-attention-700oklch(0.452 0.13 45)Needs attention ramp, step 700.no component
--opsin-status-attention-800oklch(0.362 0.105 45)Needs attention ramp, step 800.no component
--opsin-status-attention-900oklch(0.276 0.082 45)Needs attention ramp, step 900.no component
--opsin-status-attention-950oklch(0.208 0.065 45)Needs attention ramp, step 950.no component
--opsin-status-attention-surfaceoklch(0.972 0.015 45)The tinted background a component in this ramp sits on.alert-banner, status-pill
--opsin-status-attention-lineoklch(0.541 0.145 45)The boundary, icon stroke or chart mark. The lightest value in the ramp that clears the non-text floor against `surface`.alert-banner, range-bar, range-legend, status-pill
--opsin-status-attention-inkoklch(0.362 0.105 45)Text and text-sized icons on `surface`. Clears the text floor against `surface`.alert-banner, score-dial, status-pill
--opsin-status-attention-accentoklch(0.58 0.15 45)The identity fill: a bar fill, a dial track, a legend dot. Chosen for recognition, not for contrast. It must be bounded by `line` or labelled in `ink`; it is never the only thing that carries the meaning.score-dial
--opsin-status-urgent-50oklch(0.972 0.014 27)Urgent ramp, step 50.no component
--opsin-status-urgent-100oklch(0.941 0.03 27)Urgent ramp, step 100.no component
--opsin-status-urgent-200oklch(0.884 0.061 27)Urgent ramp, step 200.no component
--opsin-status-urgent-300oklch(0.806 0.11 27)Urgent ramp, step 300.no component
--opsin-status-urgent-400oklch(0.714 0.176 27)Urgent ramp, step 400.no component
--opsin-status-urgent-500oklch(0.622 0.2 27)Urgent ramp, step 500.no component
--opsin-status-urgent-600oklch(0.541 0.194 27)Urgent ramp, step 600.no component
--opsin-status-urgent-700oklch(0.452 0.176 27)Urgent ramp, step 700.no component
--opsin-status-urgent-800oklch(0.362 0.148 27)Urgent ramp, step 800.no component
--opsin-status-urgent-900oklch(0.276 0.115 27)Urgent ramp, step 900.no component
--opsin-status-urgent-950oklch(0.208 0.088 27)Urgent ramp, step 950.no component
--opsin-status-urgent-surfaceoklch(0.972 0.014 27)The tinted background a component in this ramp sits on.alert-banner, status-pill
--opsin-status-urgent-lineoklch(0.541 0.194 27)The boundary, icon stroke or chart mark. The lightest value in the ramp that clears the non-text floor against `surface`.alert-banner, range-bar, range-legend, status-pill
--opsin-status-urgent-inkoklch(0.362 0.148 27)Text and text-sized icons on `surface`. Clears the text floor against `surface`.alert-banner, score-dial, status-pill
--opsin-status-urgent-accentoklch(0.52 0.2 27)The identity fill: a bar fill, a dial track, a legend dot. Chosen for recognition, not for contrast. It must be bounded by `line` or labelled in `ink`; it is never the only thing that carries the meaning.score-dial
--opsin-status-unknown-50oklch(0.972 0.003 250)Unknown ramp, step 50.no component
--opsin-status-unknown-100oklch(0.941 0.005 250)Unknown ramp, step 100.no component
--opsin-status-unknown-200oklch(0.884 0.008 250)Unknown ramp, step 200.no component
--opsin-status-unknown-300oklch(0.806 0.013 250)Unknown ramp, step 300.no component
--opsin-status-unknown-400oklch(0.714 0.018 250)Unknown ramp, step 400.no component
--opsin-status-unknown-500oklch(0.622 0.02 250)Unknown ramp, step 500.no component
--opsin-status-unknown-600oklch(0.541 0.019 250)Unknown ramp, step 600.no component
--opsin-status-unknown-700oklch(0.452 0.018 250)Unknown ramp, step 700.no component
--opsin-status-unknown-800oklch(0.362 0.015 250)Unknown ramp, step 800.no component
--opsin-status-unknown-900oklch(0.276 0.012 250)Unknown ramp, step 900.no component
--opsin-status-unknown-950oklch(0.208 0.009 250)Unknown ramp, step 950.no component
--opsin-status-unknown-surfaceoklch(0.972 0.003 250)The tinted background a component in this ramp sits on.no component
--opsin-status-unknown-lineoklch(0.541 0.019 250)The boundary, icon stroke or chart mark. The lightest value in the ramp that clears the non-text floor against `surface`.no component
--opsin-status-unknown-inkoklch(0.362 0.015 250)Text and text-sized icons on `surface`. Clears the text floor against `surface`.no component
--opsin-status-unknown-accentoklch(0.72 0.02 250)The identity fill: a bar fill, a dial track, a legend dot. Chosen for recognition, not for contrast. It must be bounded by `line` or labelled in `ink`; it is never the only thing that carries the meaning.no component

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

  • Colour independence has the same requirement stated as an accessibility guarantee, with the generated audit.
  • Two colour axes explains why category and status are separate, and why only one of them is allowed to carry a verdict.
  • Data visualisation covers charts, which have their own redundancy problem. Category colours are not chart colours.

On this page