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.
Overview
Most phones a consumer health app runs on have a Display-P3 panel. Most of the colour written on the web is still sRGB, which on those panels means a deliberately reduced palette rendered on hardware that could show more. opsinjs takes the wide gamut where it exists without depending on it anywhere: sRGB is the baseline and is complete on its own; Display-P3 is an escalation that only ever adds chroma.
The thing this is most often confused with is colour management generally. This page is not about profiles, rendering intents or print. It is about one narrow question: how do you author a token that is more saturated on a capable display without either (a) shipping a different design to two audiences, or (b) losing a contrast guarantee you already measured.
How it works
Escalate chroma, hold lightness and hue
The base declaration is the sRGB-clamped value. The escalation re-derives the
same colour against the Display-P3 boundary at the same L and the same
H, and emits a higher C:
:root {
/* The sRGB baseline is the value every display gets. */
--opsin-status-urgent-line: oklch(0.58 0.196 25);
}
@media (color-gamut: p3) {
:root {
/* Same lightness, same hue, more chroma. */
--opsin-status-urgent-line: oklch(0.58 0.232 25);
}
}Because only chroma moves, the perceptual lightness is unchanged, so relative
luminance moves very little and every contrast measurement taken against the
sRGB value substantially holds against the P3 one. That is the entire reason the
escalation is expressed this way rather than as a second, hand-picked palette:
it keeps one design and one set of measurements instead of two of each.
scripts/check-contrast.mts measures both anyway, because "substantially holds"
is not a thing a health system should assert without checking.
The guard is a media query, not a feature query
This is the part that is easy to get wrong, and it fails silently in both directions.
/* Correct. The media query asks the DISPLAY what it can show. */
@media (color-gamut: p3) { /* … */ }
/* Correct. The feature query asks the BROWSER whether it can parse this
syntax. */
@supports (color: color(display-p3 1 0 0)) { /* … */ }
/* Wrong. `color-gamut` is a media feature, not a CSS property, so this
condition is not a supported declaration and evaluates to false everywhere.
The block never applies and nothing warns you. */
@supports (color-gamut: p3) { /* … */ }color-gamut is a media feature: it describes the output device. @supports
takes a declaration and asks whether the browser understands it. Writing
@supports (color-gamut: p3) asks whether the browser supports a CSS property
called color-gamut. The browser supports no such property, so the condition
is false, so the escalation silently never happens. The page still looks fine,
which is why this survives review: the failure mode of a missing escalation is
slightly less saturated, not broken.
The two queries answer different questions and you may legitimately need both:
the media query for "is this panel wide", the feature query for "can this engine
parse wide-gamut syntax at all". In practice the second is no longer a real
concern for oklch(), and the media query is the one that matters.
What the browser does when you ask for something out of gamut
If you author a colour that falls outside what the display can show, the browser does not error. It gamut-maps instead. Two things are worth knowing about that:
- Implementations differ. CSS Color 4 specifies a gamut-mapping algorithm that reduces chroma while holding lightness, which is the same thing our stage 4 does deliberately, but engines have historically differed and simple per-channel clipping shifts hue. Relying on the browser to do it means accepting a colour you did not choose, on a device you did not test.
- It is not measurable from your side. You cannot compute the contrast of a colour the browser is going to remap for you, because you do not know what it will remap it to. Since every opsinjs colour has to be measurable, every opsinjs colour is clamped before it is emitted.
So: never emit an out-of-gamut value and let the browser sort it out. Clamp, then escalate.
The order of the blocks matters
The escalation is a plain override of the same custom property, so it has to
come after the base declaration in source order, and the same applies to the
dark-theme escalation relative to the dark-theme base. In app/globals.css the
whole ordering is load-bearing and documented in the file header; the generated
token layer is imported at a fixed position specifically so that regeneration
cannot move it.
Using it
Author in OKLCH; think in L, C, H. The escalation only makes sense if
your ramps are already expressed with lightness and hue held constant, because
that is what makes "more chroma" a safe, isolated change.
Do not use the escalation to make a colour different. It exists to recover saturation the sRGB clamp took away, not to give wide-gamut users a nicer palette. If your P3 value has a different hue or a noticeably different lightness from its sRGB base, you have shipped two designs and one of them is untested.
Do not escalate surfaces. Large fields of high-chroma colour are unpleasant
at any gamut and worse at a wide one. The escalation is for line, where
saturation is doing work. That role covers chart strokes, dial rings and status
rules. --opsin-status-*-line and --opsin-category-*-accent are the tokens
that escalate; surfaces and inks do not.
How to verify it
Verification is genuinely awkward and it is worth being honest about why: you cannot check this from a screenshot taken on an sRGB display, and a screenshot taken on a P3 display and viewed on an sRGB one will have been converted somewhere in between.
Three things that do work:
window.matchMedia('(color-gamut: p3)').matchesin the console of the device you care about. This tells you whether the media query is matching at all, which is the failure this page exists to prevent.- Chrome DevTools' colour picker draws the sRGB gamut boundary inside the
OKLCH picker, so you can see whether an emitted value sits outside it.
<ColorScale>marks the same boundary on every ramp on this site. - Side-by-side on real hardware: put the base and escalated values next to each other on one page, on a P3 phone. If you cannot see a difference, either the media query is not matching or the escalation is not worth having for that token.
| Feature | Your browser | What happens without it |
|---|---|---|
| backdrop-filter | Checking… | Translucent material rungs fall back to their opaque colour. Layout is unchanged and the contrast floor is unchanged, because the fallback is the value the floor was measured against. |
| corner-shape (squircle) | Checking… | A plain rounded rectangle at the same radius. A taste difference, never a functional one. |
| Display-P3 colour | Checking… | The sRGB ramp. The escalation only raises chroma and keeps hue and lightness identical. Every measured contrast figure therefore holds in both gamuts. |
| linear() easing | Checking… | The browser's default easing. A spring is one custom property holding one linear() value, so there is no cubic-bezier approximation behind it: the declaration that substitutes it is invalid at computed-value time and the timing function is lost. Identical duration and identical end state; the curve is what goes. |
| :has() | Checking… | The status matrix filter and a handful of docs-only affordances stop filtering and show every row. Nothing in the product theme depends on it. |
Tested live with CSS.supports() in the browser you are reading this in. Nothing here is a claim about a browser matrix somebody maintained by hand.
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
Wide gamut is usually filed as a visual nicety. It has two real accessibility consequences and one non-consequence.
- It does not improve contrast. Contrast is a function of luminance, and the escalation deliberately does not move luminance. A pair that is uncomfortable in sRGB is uncomfortable in P3. Nobody should be reaching for P3 to fix a readability problem.
- It can make things worse if abused. High-chroma saturated fields, on a
panel that can render them harder, are exactly the surfaces people with visual
stress, migraine or photophobia report as painful. This is one of the reasons
the escalation is restricted to the
linerole. - It slightly helps colour discrimination at the margins. More chroma separation between two hues is more separation for a reader whose discrimination is reduced. It is a marginal gain and it is never the mechanism a status is allowed to depend on. See Colour blindness.
There is no gamut scope to report. scripts/check-contrast.mts reads the sRGB
value of every token and never the p3Value, so the figures published on
Contrast and APCA describe the fallback each reader
gets on a narrow-gamut display and say nothing about the escalated colour. The
first bullet above is the reason that is tolerable rather than the reason it is
fine: luminance is what contrast is made of, and the escalation moves chroma.
Measuring the P3 branch is still the outstanding work.
Nothing on this site types a measured number by hand, so an ungenerated table shows this rather than an example.
Related
- How the engine works has the stages. Stage 4 clamps and stage 5 escalates; this page is stage 5 in full.
- Contrast and APCA explains why moving chroma alone leaves a contrast measurement essentially intact.
- Tailwind v4 covers
@themeversus@theme inline, and why the escalation only works if the utilities resolve the custom property at use time.
Deriving a theme from your brand
The one-colour workflow, what the engine does with your brand colour, and the six brand colours that make it argue with you.
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.