---
title: "Gamut and Display-P3"
description: "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."
url: "https://opsinjs.pensievelabs.org/foundations/colour/gamut-and-p3"
source: "https://opsinjs.pensievelabs.org/foundations/colour/gamut-and-p3.md"
section: "Foundations"
kind: "foundation"
reviewed: "2026-09-02"
reviewer: "engineering"
aliases: ["display-p3", "wide gamut", "srgb", "color-gamut", "gamut mapping", "out of gamut"]
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> Nothing is missing from this page. The data simply does not live in
> the prose.

<PageTemplate kind="foundation" />

## Overview [#overview]

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: &#x2A;*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 [#how-it-works]

### Escalate chroma, hold lightness and hue [#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 &#x2A;*same `L` and the same
`H`**, and emits a higher `C`:

```css
: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 [#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.

```css
/* 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 [#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-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 [#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 [#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:

1. `window.matchMedia('(color-gamut: p3)').matches` in 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.
2. 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.
3. 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.

<BrowserSupport feature="color-gamut" />

## Tokens [#tokens]

<TokenTable scope="color" />

## Accessibility impact [#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 `line` role.
* **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](./colour-blindness.mdx).

<NoDataYet what="A measurement of the escalated P3 values" script="scripts/check-contrast.mts">
  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](./contrast-and-apca.mdx) 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.
</NoDataYet>

## Related [#related]

* [How the engine works](./how-the-engine-works.mdx) has the stages. Stage 4
  clamps and stage 5 escalates; this page is stage 5 in full.
* [Contrast and APCA](./contrast-and-apca.mdx) explains why moving chroma
  alone leaves a contrast measurement essentially intact.
* [Tailwind v4](../../theming/tailwind-v4.mdx) covers `@theme` versus `@theme
  inline`, and why the escalation only works if the utilities resolve the
  custom property at use time.
