Colour roles
The four roles every colour token plays are surface, line, ink and accent. A contrast guarantee attaches to a pair, never to a colour.
Overview
A ramp of eleven steps is not a colour system, because it does not tell you
which step to use. Roles are the layer that does. The four opsinjs roles are
surface, line, ink and accent, and there are four rather than thirty
because three is the number of distinct contrast obligations a colour can
have, plus one deliberate exception. accent clears no floor of its own: it is
chosen for recognition rather than for legibility, which is exactly why it may
never carry text and may never be an element's only boundary.
The important idea on this page is not the naming. It is this:
A contrast guarantee attaches to a pair, not to a colour.
--opsin-status-attention-inkis not "an accessible colour". It is the foreground half of a measured pair whose background half is--opsin-status-attention-surface. Put it on anything else and you have left the guarantee behind.
Everything below follows from that sentence.
How it works
The four roles
| Role | Sits | Measured against | Obligation |
|---|---|---|---|
surface | behind content | it is the background half | none of its own; it defines the pair |
line | on a surface, as a stroke or glyph | its own surface | non-text contrast |
ink | on a surface, as text or an icon that carries words | its own surface, and the page | text contrast |
accent | as an identity fill or a chart mark | its own surface, and the page | none it must clear; both pairs are advisory |
line and ink are separate roles rather than one "foreground" role because
their floors differ, and collapsing them means either over-darkening every chart
stroke or under-darkening every label. A stroke that is 2px wide and 40px long
is not held to the same figure as a 15px sentence, and pretending otherwise
produces a system whose charts are muddy.
The naming grammar
--opsin-<axis>-<name>-<role>
axis ∈ { category, status }
name ∈ { sleep, heart, activity, nutrition, mind, labs } when axis = category
∈ { steady, watch, attention, urgent, unknown } when axis = status
role ∈ { surface, line, ink, accent }unknown is a fifth status stem and deliberately not a fifth level. It is the
absence of an assertion, which covers a reading never taken, a stale reading, or
a reading whose reference range the product does not own. It is therefore never
ordered against the four; see
Two colour axes.
There is no fourth segment. No -hover, no -subtle, no -2. Interaction
states are derived from the role token at use time, in OKLCH, by the rules in
Interaction states. They are not additional tokens,
because a hover variant of every role on every one of the eleven stems is
another forty-four tokens nobody can hold in their head.
Tailwind utilities are exposed for each token through @theme inline, so
bg-status-attention-surface and text-status-attention-ink resolve to the same custom
properties and follow the theme, the gamut escalation and the media-query
overrides automatically. Two of those utilities are shorter than they look, and
the two axes disagree about which role the bare name means: bg-status-urgent
resolves to the line, bg-category-heart resolves to the accent. The
other roles are always spelt out. Raw var() access is supported for consumers
who are not on Tailwind; see CSS variables.
The category axis also exposes one role fewer, and this is the rule that keeps
the two axes apart. surface is bridged to a utility only on the status axis, so
a status can tint a card with bg-status-attention-surface while a category has
line, ink and accent and no surface utility to reach for. The reason is not
chroma. Both axes run the same lightness ladder and the same chroma envelope, so
at a given step a category surface and a status surface measure Lc 0.00 and WCAG
1.00 against each other, which is no difference a reader could use. So a category
paints a stroke, a word or an identity fill and never a tinted card, and only a
status owns the tinted surface. A category surface value still exists as the
measurement reference its ink is gated against, and it is what the ramp
specimen shows, but nothing paints a card with it.
Chrome roles are a separate namespace
Alongside the two axes there is an ordinary application palette.
--background, --foreground, --card, --muted, --muted-foreground,
--border and --input are inherited from the shadcn convention so that a team
already using it is not asked to relearn anything. These seven are unprefixed on
purpose: they are the neutral system, they carry no clinical meaning, and they
are the correct default for almost everything on a screen.
Two more roles ship under the same convention and are not neutral. --ring and
--primary are a brand blue, seeded at chroma 0.13 and 0.135, which is as
chromatic as the category axis, whose labs seed sits at 0.11 and whose heart
seed at 0.17. Calling them neutral would be wrong. They carry no clinical
meaning either, but they are a third saturated presence on the screen, one that
competes with both measurement axes for the reader's eye, so a focus ring or a
primary button is a deliberate accent rather than a quiet default. Whether the
chrome palette should stay this chromatic is a design question the system has
not yet closed.
The --opsin-* tokens are the exception you reach for when the element is
genuinely saying something about a measurement.
If most of your screen is coloured from the two axes, the screen is shouting. See Alarm fatigue.
The declared pair set
scripts/check-contrast.mts measures a declared list of pairs, in both
themes and in sRGB. Each measured pair is gated, advisory or exempt, the
three states Contrast and APCA defines, and the
clauses below say which state each pair takes rather than restating what the
states mean. The list is, exactly:
- every
inkagainst thesurfaceof the same axis and name, at the body-text floor; - every
lineagainst that same surface, at the non-text floor; - every
accentagainst that same surface and against the page, at the non-text floor and advisory rather than gated, because an identity fill is chosen for recognition and is not something a reader has to read; - every
inkagainst the page, at the body-text floor, because a status or category word frequently sits on the page rather than on its own tint; - the neutral roles: body text and secondary text against the page at the body-text floor; the card hairline and the focus ring against both the page and a card at the non-text floor, gated on the chrome tokens the theme renders; and one grouping hairline between list rows against the page, left advisory because a row divider carries no meaning that SC 1.4.11 covers;
- body text against every material rung's opaque fallback,
scrimexcepted because nothing is written on a dimmer (see The contrast floor).
"The page" in clauses 3 to 6 is --opsin-chrome-background, which resolves to
--opsin-neutral-50 in light and --opsin-neutral-950 in dark. It is not
--card, which is --opsin-chrome-card and resolves to --opsin-neutral-0 in
light and --opsin-neutral-900 in dark. In both themes the card sits one ladder
step lighter than the page, so a card is one step away from the measured pair
rather than inside it.
Anything not on that list is unmeasured. Unmeasured is not the same as
failing, because an unmeasured pair may well be fine. It is still not something
this system is promising you, and a health product should not be built on a
promise nobody made. The focus ring used to be the gap worth knowing about, and
it no longer is: --ring against the page and against a card is measured and
gated in both themes, on the chrome tokens the theme actually renders, so the
boundary a keyboard reader depends on is now something the system checks rather
than something it hopes.
One honest gap survives the measurement. A 1px hairline on a near-black dark
page cannot reach the APCA Lc 45 non-text floor at any value the neutral ladder
holds, so the dark card hairline is measured, published and below the APCA floor
while it still clears the WCAG 3:1 ratio. It is gated rather than hidden, which
catches any further lightening of the border, and the shortfall is stated at its
source in app/product.css. Closing it needs a boundary treatment the neutral
ladder does not yet carry.
The neutral and chrome pairs, measured in both themes, render below. The card hairline rows sit in the neutral scope, and the focus ring rows in the chrome scope, because the ring is a chrome role rather than a neutral one.
| Pair | Theme | APCA Lc | WCAG 2.2 | Floor |
|---|---|---|---|---|
| body text on the page | light | 101.6 | 17.18:1 | Pass |
| body text on the page | dark | -100.5 | 18.00:1 | Pass |
| body text on a card | light | 104.7 | 17.96:1 | Pass |
| body text on a card | dark | -99.6 | 16.32:1 | Pass |
| body text on the muted ground | light | 98.1 | 16.32:1 | Pass |
| body text on the muted ground | dark | -97.5 | 13.77:1 | Pass |
| secondary text on the page | light | 83.9 | 7.42:1 | Pass |
| secondary text on the page | dark | -80.7 | 13.44:1 | Pass |
| secondary text on a card | light | 87.0 | 7.76:1 | Pass |
| secondary text on a card | dark | -79.8 | 12.19:1 | Pass |
| secondary text on the muted ground | light | 80.4 | 7.05:1 | Pass |
| secondary text on the muted ground | dark | -77.8 | 10.28:1 | Pass |
| a hairline boundary on the page | light | 19.3 | 1.41:1 | Below floor |
| a hairline boundary on the page | dark | -8.2 | 1.90:1 | Below floor |
| a hairline boundary on a card | light | 22.4 | 1.47:1 | Below floor |
| a hairline boundary on a card | dark | -7.3 | 1.72:1 | Below floor |
| a hairline boundary on the muted ground | light | 15.8 | 1.34:1 | Below floor |
| a hairline boundary on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on the page | light | 69.8 | 4.52:1 | Pass |
| a placeholder boundary on the page | dark | -51.0 | 7.62:1 | Pass |
| a placeholder boundary on a card | light | 72.8 | 4.72:1 | Pass |
| a placeholder boundary on a card | dark | -50.1 | 6.91:1 | Pass |
| a placeholder boundary on the muted ground | light | 66.2 | 4.29:1 | Pass |
| a placeholder boundary on the muted ground | dark | -48.1 | 5.83:1 | Pass |
| a placeholder fill on the page | light | 8.3 | 1.18:1 | Below floor |
| a placeholder fill on the page | dark | -8.2 | 1.90:1 | Below floor |
| a placeholder fill on a card | light | 11.4 | 1.23:1 | Below floor |
| a placeholder fill on a card | dark | -7.3 | 1.72:1 | Below floor |
| a placeholder fill on the muted ground | light | 0.0 | 1.12:1 | Below floor |
| a placeholder fill on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on its own fill | light | 58.9 | 3.84:1 | Pass |
| a placeholder boundary on its own fill | dark | -41.5 | 4.01:1 | Below floor |
| the card hairline on the page | light | 69.8 | 4.52:1 | Pass |
| the card hairline on the page | dark | -28.7 | 4.19:1 | Below floor |
| the card hairline on a card | light | 72.8 | 4.72:1 | Pass |
| the card hairline on a card | dark | -27.9 | 3.80:1 | Below floor |
| Pair | Theme | APCA Lc | WCAG 2.2 | Floor |
|---|---|---|---|---|
| the focus ring on the page | light | 70.0 | 4.62:1 | Pass |
| the focus ring on the page | dark | -47.0 | 6.91:1 | Pass |
| the focus ring on a card | light | 73.1 | 4.83:1 | Pass |
| the focus ring on a card | dark | -46.2 | 6.27:1 | Pass |
Using it
Use a whole pair or neither half. A status or category word on a neutral
card is ink, not line: ink against the page is clause 4, measured and
gated in both themes, while line is tuned to the non-text floor, so moving a
word onto it lowers the contrast the reader actually gets. Two cautions come
with that. The measured ground is the page rather than --card, which is the
same colour in light and one step lighter in dark, so in dark a word on a card
inherits a guarantee made a step away from it. And line on a neutral
ground is not measured at all. It is the role for a stroke, a boundary or a
chart mark, and its floor is guaranteed only against its own surface.
Never take ink from one axis onto a surface from the other. Heart ink
on an urgent surface is both an unmeasured pair and a violation of the
never-mix rule in Two colour axes. It is the
most common way the axis invariant is broken in practice, because each half
looks defensible on its own.
Do not invent a role to solve a layout problem. If a surface needs a slightly different tint to separate it from its parent, the answer is a material rung or a border, not a new colour token.
Do
A tinted status card: --opsin-status-attention-surface behind
--opsin-status-attention-ink, with a 1px --opsin-status-attention-line edge. All
three halves come from one axis and one name, so every pair on the card is
in the declared set and measured in both themes.
Don’t
A neutral card with --opsin-status-attention-accent for the heading
"because the ink read too dark". The accent role is chosen for recognition
rather than legibility: its two pairs are measured at the non-text floor and
are advisory even there, so the heading is now text held to no text floor at
all. The reader also has a coloured heading with no coloured surface to
explain what the colour means.
Extending is a supported operation; redefining is not. Adding a seventh category adds four tokens and no risk, because categories carry no verdict. Redefining a status role changes what the product asserts about somebody's health, which is why Status palettes exists as a page-length argument rather than a recipe.
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
The pair rule is the accessibility mechanism on this page, and it has a consequence people find unintuitive: a token cannot be accessible on its own. Contrast is a relationship. A design review that says "this colour passes" has not said anything checkable, and a token table that prints a single contrast number per colour is printing a number against an assumed background it did not declare.
That is why Contrast and APCA and every
<ContrastReport> on this site print a pair, a theme and a floor, and why
<TokenSwatch> shows a token's resolved value in both themes together with the
pair it was measured in.
Two related obligations sit with the implementer rather than with the tokens:
- The
linerole is not a text role. If you set body copy in alinecolour it may well be below the text floor. The role name is the warning. - Icons that carry words are
ink, notline. An icon whose meaning is duplicated by adjacent text is decorative and may useline; an icon that is the only carrier of a piece of information is text as far as WCAG 2.2 SC 1.4.3 is concerned, and it should not exist at all under Colour independence.
Related
- How the engine works covers both stages. Stage 6 is where a ramp becomes these four roles, and stage 7 is where the pairs are validated.
- Interaction states explains why hover, press and selected are derived at use time rather than shipped as more tokens.
- Token architecture has the three-tier model, and the rule that a component never references a primitive.