Contrast and APCA
Both contrast models side by side. WCAG 2.2 is the conformance obligation, APCA the design tool, and this page states the floor the system will not ship below.
Overview
There are two ways to put a number on how readable text is against its background, they disagree, and most design systems quietly pick one and hope nobody notices. opsinjs publishes both, for every measured pair, in both themes, and states which one it is bound by:
WCAG 2.2 is the obligation. APCA is the tool.
A pair ships only if it clears the WCAG 2.2 requirement, because that is what conformance, procurement and the law are written against. APCA is what the derivation engine optimises with, and it is published so that a designer can see the cases where a pair is technically conformant and still hard to read.
The thing this is most often confused with is a contrast checker. A checker tells you about one pair you thought to check. This page is about a floor that is applied to the whole declared pair set, measured in CI, with the gated set held against regression by a nightly run, so that nobody has to remember to check.
How it works
WCAG 2.2 contrast ratio
The ratio is computed from relative luminance:
ratio = (L_lighter + 0.05) / (L_darker + 0.05)where L is relative luminance from the sRGB channel values after
linearisation. It runs from 1:1 to 21:1. Its properties, stated without
enthusiasm:
- It is symmetric. Black text on white and white text on black score identically, although they do not read identically, particularly at small sizes where light-on-dark halates.
- It is size-blind except for one carve-out, so 14px and 40px text are held to the same figure. The carve-out is the 3:1 allowance for large text.
- The
+ 0.05constant compresses the dark end, which is why two dark greys that are visibly different can score almost identically, and why dark themes are the ones that most often look fine and measure badly.
It is nonetheless the number that matters, because SC 1.4.3, SC 1.4.11 and every conformance claim built on them are written in terms of it.
APCA lightness contrast (Lc)
APCA is the Accessible Perceptual Contrast Algorithm, developed as part of the
work toward WCAG 3, and it produces a value called Lc on a roughly 0 to 106
scale. It differs from the ratio in three ways that matter:
- It is polarity-signed. Dark text on a light background gives a positive
Lc; light text on a dark background gives a negative one. The two are not interchangeable, and the model says so. - It is size- and weight-aware. The published guidance is a lookup table:
the
Lcyou need depends on the font size and weight you are actually using, so a 40px result value and a 13px unit label are not asked for the same number. - It behaves sensibly at the ends. Two dark greys that WCAG 2.2 cannot tell
apart have visibly different
Lc.
The important caveat, and it is the reason APCA is not our gate: APCA is a
draft. It is not a normative part of WCAG 2.2, its lookup tables have changed
across revisions, and a conformance claim cannot be built on it today. We
therefore pin our own Lc thresholds in tokens/color.json, record the date we
pinned them, and re-examine when the upstream draft moves. We do not import a
moving target into a health product's safety argument.
The published floor
These are thresholds we adopt, not measurements. They are what
scripts/check-contrast.mts tests every declared pair against, in light and
dark. Where the two models disagree, both have to pass.
| What the pair carries | WCAG 2.2 | APCA |Lc| |
|---|---|---|
| A health value a person may act on | ≥ 4.5:1 | ≥ 90 |
| Body text, labels, ordinary UI copy | ≥ 4.5:1 | ≥ 75 |
| Large text at 24px+ regular or 19px+ bold | ≥ 3:1 | ≥ 60 |
| A meaningful line: status rule, chart stroke, dial ring, focus ring | ≥ 3:1 | ≥ 45 |
| Disabled or purely decorative, carrying no information | none | none |
Two of those rows are opinions rather than requirements and are marked as such:
The floor above is not a single switch. Every measured pair sits in one of
three states, and whether a pair that falls below its floor blocks a build
depends on which. The tables further down print each pair as Pass or Below floor without naming its state, so the three are defined here.
Gated. The pair is in the regression baseline. A commit that drives it below
its floor fails the nightly regression run. Body text, an ink role against its own
surface or against the page, and the neutral hairlines are gated, because a
reader has to read them or leans on them to tell two regions apart.
Advisory. The pair is measured and published, but it sits outside the
regression baseline, because it carries recognition rather than words. An
accent fill is the case: it may never carry text and may never be an element's
only boundary, so the nightly regression run does not block on it. A non-text
advisory pair that measures below its floor does fail the --strict run, and its
count is published beside the "failing" figure, so a "0 failing" total can never
stand on its own while an accent or a hairline sits below the non-text floor.
Exempt. The pair is outside the floor's scope, and the reason sits beside it. A band fill measured against the page, a status tint measured on a card, a status ring measured against its own surface, and a placeholder fill on the page are exempt: none of them is text, and none is the only thing separating two regions, so no floor applies.
Where the numbers on this site come from
Every figure published on this site is measured, in CI, by hand-written
implementations of both models in lib/color/, and rendered by
<ContrastReport>. None is typed by a person, and a regression is caught by the
nightly run rather than by an individual pull request. The current results for
the two clinical axes:
| Pair | Theme | APCA Lc | WCAG 2.2 | Floor |
|---|---|---|---|---|
| attention ink on attention surface | light | 89.4 | 10.35:1 | Pass |
| attention ink on attention surface | dark | -81.1 | 12.54:1 | Pass |
| attention line on attention surface | light | 70.0 | 4.90:1 | Pass |
| attention line on attention surface | dark | -65.5 | 9.54:1 | Pass |
| attention accent on attention surface | light | 65.1 | 4.18:1 | Pass |
| attention accent on attention surface | dark | -49.4 | 6.80:1 | Pass |
| attention ink on the attention band | light | 22.4 | 2.48:1 | Below floor |
| attention ink on the attention band | dark | -31.0 | 1.84:1 | Below floor |
| attention accent on the page | light | 67.8 | 4.35:1 | Pass |
| attention accent on the page | dark | -50.5 | 7.47:1 | Pass |
| attention ink on the page | light | 92.2 | 10.77:1 | Pass |
| attention ink on the page | dark | -82.3 | 13.76:1 | Pass |
| steady ink on steady surface | light | 88.3 | 9.62:1 | Pass |
| steady ink on steady surface | dark | -82.9 | 12.56:1 | Pass |
| steady line on steady surface | light | 67.5 | 4.45:1 | Pass |
| steady line on steady surface | dark | -68.9 | 9.91:1 | Pass |
| steady accent on steady surface | light | 56.4 | 3.21:1 | Pass |
| steady accent on steady surface | dark | -53.2 | 7.25:1 | Pass |
| steady ink on the steady band | light | 30.0 | 3.00:1 | Below floor |
| steady ink on the steady band | dark | -28.8 | 1.73:1 | Below floor |
| steady accent on the page | light | 58.6 | 3.31:1 | Pass |
| steady accent on the page | dark | -54.5 | 8.19:1 | Pass |
| steady ink on the page | light | 90.6 | 9.94:1 | Pass |
| steady ink on the page | dark | -84.2 | 14.20:1 | Pass |
| unknown ink on unknown surface | light | 89.5 | 9.98:1 | Pass |
| unknown ink on unknown surface | dark | -82.2 | 12.56:1 | Pass |
| unknown line on unknown surface | light | 69.2 | 4.64:1 | Pass |
| unknown line on unknown surface | dark | -67.2 | 9.72:1 | Pass |
| unknown accent on unknown surface | light | 43.1 | 2.29:1 | Below floor |
| unknown accent on unknown surface | dark | -51.3 | 7.05:1 | Pass |
| unknown accent on the page | light | 45.6 | 2.37:1 | Below floor |
| unknown accent on the page | dark | -52.3 | 7.84:1 | Pass |
| unknown ink on the page | light | 92.0 | 10.34:1 | Pass |
| unknown ink on the page | dark | -83.1 | 13.97:1 | Pass |
| urgent ink on urgent surface | light | 88.2 | 10.58:1 | Pass |
| urgent ink on urgent surface | dark | -80.3 | 12.52:1 | Pass |
| urgent line on urgent surface | light | 70.0 | 5.09:1 | Pass |
| urgent line on urgent surface | dark | -64.8 | 9.52:1 | Pass |
| urgent accent on urgent surface | light | 72.3 | 5.58:1 | Pass |
| urgent accent on urgent surface | dark | -48.0 | 6.63:1 | Pass |
| urgent ink on the urgent band | light | 13.9 | 1.90:1 | Below floor |
| urgent ink on the urgent band | dark | -31.5 | 1.89:1 | Below floor |
| urgent accent on the page | light | 75.3 | 5.84:1 | Pass |
| urgent accent on the page | dark | -49.5 | 7.24:1 | Pass |
| urgent ink on the page | light | 91.2 | 11.06:1 | Pass |
| urgent ink on the page | dark | -81.8 | 13.66:1 | Pass |
| watch ink on watch surface | light | 89.4 | 10.02:1 | Pass |
| watch ink on watch surface | dark | -82.1 | 12.58:1 | Pass |
| watch line on watch surface | light | 69.5 | 4.72:1 | Pass |
| watch line on watch surface | dark | -66.8 | 9.68:1 | Pass |
| watch accent on watch surface | light | 61.7 | 3.72:1 | Pass |
| watch accent on watch surface | dark | -50.7 | 6.95:1 | Pass |
| watch ink on the watch band | light | 25.7 | 2.70:1 | Below floor |
| watch ink on the watch band | dark | -30.6 | 1.81:1 | Below floor |
| watch accent on the page | light | 64.2 | 3.85:1 | Pass |
| watch accent on the page | dark | -51.7 | 7.71:1 | Pass |
| watch ink on the page | light | 91.9 | 10.38:1 | Pass |
| watch ink on the page | dark | -83.1 | 13.95:1 | Pass |
| attention surface on the card material | light | 0.0 | 1.09:1 | Below floor |
| attention surface on the card material | dark | 0.0 | 1.00:1 | Below floor |
| steady surface on the card material | light | 0.0 | 1.08:1 | Below floor |
| steady surface on the card material | dark | 0.0 | 1.03:1 | Below floor |
| urgent surface on the card material | light | 0.0 | 1.09:1 | Below floor |
| urgent surface on the card material | dark | 0.0 | 1.01:1 | Below floor |
| watch surface on the card material | light | 0.0 | 1.08:1 | Below floor |
| watch surface on the card material | dark | 0.0 | 1.01:1 | Below floor |
| the focus ring on the attention surface | light | 67.3 | 4.44:1 | Pass |
| the focus ring on the attention surface | dark | -45.9 | 6.30:1 | Pass |
| attention ink on the neutral hover fill | light | 88.6 | 10.23:1 | Pass |
| attention ink on the neutral hover fill | dark | -79.3 | 10.53:1 | Pass |
| the focus ring on the steady surface | light | 67.8 | 4.47:1 | Pass |
| the focus ring on the steady surface | dark | -45.8 | 6.12:1 | Pass |
| steady ink on the neutral hover fill | light | 87.0 | 9.43:1 | Pass |
| steady ink on the neutral hover fill | dark | -81.3 | 10.86:1 | Pass |
| the focus ring on the urgent surface | light | 67.0 | 4.42:1 | Pass |
| the focus ring on the urgent surface | dark | -45.5 | 6.34:1 | Pass |
| urgent ink on the neutral hover fill | light | 87.7 | 10.50:1 | Pass |
| urgent ink on the neutral hover fill | dark | -78.9 | 10.46:1 | Pass |
| the focus ring on the watch surface | light | 67.6 | 4.46:1 | Pass |
| the focus ring on the watch surface | dark | -46.0 | 6.24:1 | Pass |
| watch ink on the neutral hover fill | light | 88.3 | 9.86:1 | Pass |
| watch ink on the neutral hover fill | dark | -80.1 | 10.67:1 | Pass |
| Pair | Theme | APCA Lc | WCAG 2.2 | Floor |
|---|---|---|---|---|
| activity ink on activity surface | light | 88.5 | 9.61:1 | Pass |
| activity ink on activity surface | dark | -83.1 | 12.58:1 | Pass |
| activity line on activity surface | light | 67.6 | 4.42:1 | Pass |
| activity line on activity surface | dark | -69.0 | 9.93:1 | Pass |
| activity accent on activity surface | light | 44.1 | 2.34:1 | Below floor |
| activity accent on activity surface | dark | -53.3 | 7.26:1 | Pass |
| activity accent on the page | light | 46.1 | 2.41:1 | Below floor |
| activity accent on the page | dark | -54.6 | 8.22:1 | Pass |
| activity ink on the page | light | 90.5 | 9.90:1 | Pass |
| activity ink on the page | dark | -84.4 | 14.24:1 | Pass |
| activity line on the band | light | 66.1 | 4.32:1 | Pass |
| activity line on the band | dark | -67.4 | 8.60:1 | Pass |
| heart ink on heart surface | light | 89.1 | 10.51:1 | Pass |
| heart ink on heart surface | dark | -80.5 | 12.55:1 | Pass |
| heart line on heart surface | light | 70.4 | 5.05:1 | Pass |
| heart line on heart surface | dark | -64.5 | 9.46:1 | Pass |
| heart accent on heart surface | light | 60.3 | 3.64:1 | Pass |
| heart accent on heart surface | dark | -48.2 | 6.68:1 | Pass |
| heart accent on the page | light | 63.2 | 3.81:1 | Pass |
| heart accent on the page | dark | -49.5 | 7.27:1 | Pass |
| heart ink on the page | light | 92.0 | 10.97:1 | Pass |
| heart ink on the page | dark | -81.8 | 13.65:1 | Pass |
| heart line on the band | light | 69.9 | 5.01:1 | Pass |
| heart line on the band | dark | -62.8 | 7.88:1 | Pass |
| labs ink on labs surface | light | 88.8 | 9.78:1 | Pass |
| labs ink on labs surface | dark | -83.1 | 12.69:1 | Pass |
| labs line on labs surface | light | 67.9 | 4.51:1 | Pass |
| labs line on labs surface | dark | -68.4 | 9.90:1 | Pass |
| labs accent on labs surface | light | 59.3 | 3.49:1 | Pass |
| labs accent on labs surface | dark | -52.7 | 7.21:1 | Pass |
| labs accent on the page | light | 61.5 | 3.60:1 | Pass |
| labs accent on the page | dark | -53.9 | 8.07:1 | Pass |
| labs ink on the page | light | 90.9 | 10.09:1 | Pass |
| labs ink on the page | dark | -84.3 | 14.22:1 | Pass |
| labs line on the band | light | 66.5 | 4.42:1 | Pass |
| labs line on the band | dark | -66.7 | 8.49:1 | Pass |
| mind ink on mind surface | light | 89.9 | 10.36:1 | Pass |
| mind ink on mind surface | dark | -81.3 | 12.56:1 | Pass |
| mind line on mind surface | light | 70.7 | 4.94:1 | Pass |
| mind line on mind surface | dark | -65.4 | 9.54:1 | Pass |
| mind accent on mind surface | light | 60.0 | 3.55:1 | Pass |
| mind accent on mind surface | dark | -49.1 | 6.77:1 | Pass |
| mind accent on the page | light | 62.8 | 3.70:1 | Pass |
| mind accent on the page | dark | -50.0 | 7.42:1 | Pass |
| mind ink on the page | light | 92.7 | 10.80:1 | Pass |
| mind ink on the page | dark | -82.2 | 13.77:1 | Pass |
| mind line on the band | light | 70.0 | 4.89:1 | Pass |
| mind line on the band | dark | -63.4 | 8.00:1 | Pass |
| nutrition ink on nutrition surface | light | 89.3 | 10.06:1 | Pass |
| nutrition ink on nutrition surface | dark | -81.9 | 12.55:1 | Pass |
| nutrition line on nutrition surface | light | 69.7 | 4.78:1 | Pass |
| nutrition line on nutrition surface | dark | -66.7 | 9.66:1 | Pass |
| nutrition accent on nutrition surface | light | 39.2 | 2.11:1 | Below floor |
| nutrition accent on nutrition surface | dark | -50.2 | 6.88:1 | Pass |
| nutrition accent on the page | light | 41.8 | 2.19:1 | Below floor |
| nutrition accent on the page | dark | -51.3 | 7.63:1 | Pass |
| nutrition ink on the page | light | 91.9 | 10.44:1 | Pass |
| nutrition ink on the page | dark | -82.9 | 13.91:1 | Pass |
| nutrition line on the band | light | 68.8 | 4.71:1 | Pass |
| nutrition line on the band | dark | -64.8 | 8.19:1 | Pass |
| sleep ink on sleep surface | light | 89.6 | 10.19:1 | Pass |
| sleep ink on sleep surface | dark | -81.9 | 12.60:1 | Pass |
| sleep line on sleep surface | light | 70.0 | 4.80:1 | Pass |
| sleep line on sleep surface | dark | -66.4 | 9.65:1 | Pass |
| sleep accent on sleep surface | light | 64.6 | 4.05:1 | Pass |
| sleep accent on sleep surface | dark | -50.4 | 6.94:1 | Pass |
| sleep accent on the page | light | 67.3 | 4.22:1 | Pass |
| sleep accent on the page | dark | -51.4 | 7.67:1 | Pass |
| sleep ink on the page | light | 92.3 | 10.60:1 | Pass |
| sleep ink on the page | dark | -82.9 | 13.92:1 | Pass |
| sleep line on the band | light | 69.1 | 4.75:1 | Pass |
| sleep line on the band | dark | -64.5 | 8.16:1 | Pass |
| activity ink on the card material | light | 93.6 | 10.34:1 | Pass |
| activity ink on the card material | dark | -83.5 | 12.91:1 | Pass |
| heart ink on the card material | light | 95.1 | 11.47:1 | Pass |
| heart ink on the card material | dark | -80.9 | 12.38:1 | Pass |
| labs ink on the card material | light | 94.0 | 10.54:1 | Pass |
| labs ink on the card material | dark | -83.5 | 12.90:1 | Pass |
| mind ink on the card material | light | 95.7 | 11.28:1 | Pass |
| mind ink on the card material | dark | -81.4 | 12.49:1 | Pass |
| nutrition ink on the card material | light | 95.0 | 10.91:1 | Pass |
| nutrition ink on the card material | dark | -82.0 | 12.61:1 | Pass |
| sleep ink on the card material | light | 95.4 | 11.08:1 | Pass |
| sleep ink on the card material | dark | -82.1 | 12.62:1 | Pass |
Using it
Check a pair now
Your reading is 148/92
Higher than the range your clinic set for you.
- WCAG 2.2 ratio
- 10.86:1
- Clears 1.4.3 for body text.
- APCA Lc
- …
- Perceptual lightness contrast. Polarity-sensitive: swapping the two colours changes it.
The oracle takes any two colours, prints both models, and states the verdict against the floor above. It is the same code path the build uses. If it and the build disagree, that is a bug in this repository, not a rounding difference.
How to read a disagreement
The two models disagree most often in three situations, and each has a different correct response.
Passes WCAG, low Lc. Usually a dark-on-dark pair in a dark theme, where the
+ 0.05 constant is flattering it. Trust Lc; the pair is genuinely hard to
read. This is the single most common way a dark theme ships worse than its light
counterpart.
Fails WCAG, comfortable Lc. Usually large, bold, dark-on-light type. It may
well be perfectly readable, and you still cannot ship it, because the obligation
is the ratio. Adjust the colour.
Both pass, still looks wrong. Check what is actually behind the text. If the answer is a translucent surface, an image or a video, neither number applies, because both models assume a known, opaque background. That case is The contrast floor.
Rules of thumb that are actually true
- Contrast is a property of a pair, so never say "this colour passes".
- Fixing contrast by lightening the background changes the design; fixing it by darkening the foreground usually does not. The engine only ever moves the foreground for the same reason.
- Opacity on text is a contrast change.
text-foreground/70is a new, unmeasured colour, and it is the most common accidental floor violation in Tailwind codebases. - Placeholder text is text. So is a disabled label somebody still has to read to understand why the control is disabled.
Tokens
| Token | What it controls | Used 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
This page is the accessibility impact of the colour system, so what follows is the boundary rather than a restatement.
What is guaranteed. The gated set clears both models at the floor above, in both themes and in sRGB. The nightly run measures the whole gated set and fails on any pair that has dropped below its floor, so a regression is caught within a day rather than on the commit that introduced it. Advisory pairs are published as well, and one may sit below its floor: the number that do is printed beside the "failing" figure in the summary, so a clean total never hides a sub-floor accent or hairline. Exempt pairs are outside the floor's scope by design. The list of declared pairs, and which state each takes, is enumerated in Colour roles, and the measured results are published, dated, on Contrast conformance.
What is not guaranteed, and is yours.
- Any pair you assemble that is not in the declared set.
- Text over imagery, video, gradients or any translucent surface.
- Text you have made semi-transparent.
- Text rendered at a weight or size other than the one the row above assumes.
Lcis size-aware and a thin 12px label is not the same problem as a 15px regular one. forced-colors: active, where the operating system replaces the palette entirely and the only thing that survives is your markup.
What contrast does not fix. Meeting the floor does not make a colour-coded status accessible; it makes it visible. A reader who cannot distinguish the four hues gets no benefit from any of these numbers, which is why the four levels always ship with a word. See Colour independence.
Related
- Colour blindness has the redundancy requirement that contrast alone cannot satisfy.
- The contrast floor covers the same problem when the background is not a known colour, and the worst-case composite that replaces it.
- Contrast conformance is the dated, generated conformance record for the whole token set.
Gamut and Display-P3
sRGB is the baseline, Display-P3 is a chroma escalation behind a media query, and the guard has to be a media query rather than a feature query.
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.