Dark mode
Dark is re-derived from the same ladder rather than inverted, and there are seven invariants it has to preserve on the way.
Overview
opsinjs is derived light-first and then re-derived for dark. It is not inverted, not mirrored, and not "the same palette with the lightness subtracted from one". Those shortcuts all produce a dark theme in which the four status levels have quietly swapped their relative weights, which in a health product means the screen is now emphasising the wrong thing.
Light is the default because of who the reader is: somebody checking a measurement, often outdoors, often at whatever brightness their phone decided on. Dark is a preference we support properly rather than a mode we prefer, and "properly" here has a consequence worth stating plainly, because it surprises people: a reader who has expressed no preference in the product resolves to light, including a reader whose operating system says dark. Following the system is offered in the theme control and is not the default, because the contrast floor was authored against light and that is the theme the guarantee was measured in. Choosing dark, once, is remembered.
The thing this is most often confused with is the implementation of theme switching. The class strategy, the provider and the flash of the wrong theme on first paint are mechanics, and they live in Dark mode in the handbook. This page is about what the colours have to mean.
How it works
Seven invariants
Dark is allowed to change a great deal. These seven things it may not change,
and scripts/check-contrast.mts plus review enforce them between them.
1. Hue is preserved exactly. Heart is hue 15 in both themes; urgent is
hue 27 in both. A theme where the reds drift warmer at night is a theme where
"the same measurement" is not the same colour, and the category axis's entire
claim is that a colour is an identity.
2. The ordering of the four status levels is preserved. Whatever else moves,
steady → watch → attention → urgent remains an ordered sequence and the
components render it in that order.
3. The floor is identical. Dark is not permitted a lower contrast requirement. Every pair is measured separately in dark and a dark-only regression fails the build exactly as a light-only one does. This is the invariant most often broken elsewhere, because dark themes flatter the WCAG 2.2 ratio at the dark end. See Contrast and APCA.
4. Surfaces gain chroma; inks lose it. This is the counter-intuitive one and
it is not a stylistic choice. At low lightness a tint needs more chroma to read
as coloured at all, so the dark status and category surfaces are authored with
higher C than their light counterparts. Those surfaces sit at ladder step
950, a deliberately quiet rung, so the tint still reads as coloured without the
fill becoming a heavy slab that competes with the content resting on it. Light
text, conversely, is
uncomfortable when it is saturated, because saturated light text glares.
Dark-theme inks therefore carry less C than light-theme inks. Both
movements are at constant hue.
5. Pure black is not a background. The dark background sits at L 0.145,
the end of the neutral ramp, with a trace of chroma rather than at L 0. Three reasons: on OLED panels,
pure black next to moving content smears; high-contrast black-to-white text
produces halation that readers with astigmatism report as blurring; and against
true black every raised surface has to be grey, which flattens the material
ladder to a single step.
6. Elevation inverts its mechanism. In light, a surface rises by getting
whiter and casting a shadow. In dark, a shadow against a near-black background
is close to invisible, so a surface rises by getting lighter and by carrying a
brighter hairline. The hairline is a solid ladder grey, --opsin-chrome-border
at neutral step 500, the same value the light theme uses rather than a low-alpha
white, so one measured value draws every boundary and a border over a raised
surface still separates against the near-black ground. The rung-by-rung consequences are in
The ladder.
7. color-scheme is declared. The root carries color-scheme so that form
controls, scrollbars, the caret and the browser's own UI follow the theme. A
dark page with light native scrollbars is the classic sign that a theme was
implemented with colours alone.
What is actually different
| Light | Dark | |
|---|---|---|
| Status surface | very light tint, low chroma | dark tint, higher chroma |
| Status ink | dark, higher chroma | light, lower chroma |
| Status line | mid lightness | lifted, so it still reads on a dark surface |
| Border | solid neutral | solid neutral, the same value |
| Elevation cue | shadow | lighter surface plus brighter hairline |
| Background | near-white | near-black, never black |
Why not inversion
Inverting lightness with L' = 1 - L is the tempting one-line implementation
and it fails on invariant 4 and invariant 6 simultaneously. It also fails on
something subtler: perceptual lightness is not symmetric in its effect on
apparent chroma, so an inverted palette's relative saturations are wrong even
though every individual value is a defensible colour. The four status levels stop
being equally weighted, and one of them starts winning the screen.
filter: invert() on a whole page is worse still, because it inverts imagery,
photographs, body diagrams and any medical illustration along with the interface.
Using it
Design light first, then check dark, then check dark on a real phone at low brightness. The third step is not optional and it is where dark-theme problems actually surface. A pair that measures fine at 100% brightness in a bright room can be unreadable at 20% in a dark one, which is precisely the situation a dark theme exists for.
Do not use dark mode to add drama. Deeper backgrounds and heavier accents are the reflex, and in a health product they produce a screen where an ordinary reading looks alarming. The status axis is the only thing on the page permitted to raise its voice.
Quieting the dark surfaces to step 950 leaves one gap that the fill alone
cannot close. watch and attention now share that rung, so their dark
surfaces sit at the same lightness and measure roughly Lc 0.00 against each
other; the 37 degree hue gap that separates them on a light ground collapses
toward two browns on a dark one. This is the never-mix doctrine working as
intended rather than a defect, because a status surface is never the thing
that carries the level. The line, the word and the icon do that, in both
themes, so a reader tells watch from attention by the label and the
boundary and not by the fill. Separating the two fills as well would need a
per-level lightness or chroma decision the shared ladder does not yet carry.
Test the transition, not just the two ends. Anything that animates a colour
across a theme change should either be instantaneous or respect
prefers-reduced-motion; a slow cross-fade of an entire screen is a
disorientation risk. See Reduced motion.
Do
Re-derive the dark ramps from the same ladder, hold the hue, raise the chroma on surfaces, lower it on inks, and measure every pair again.
Don’t
Ship dark by swapping --background and --foreground and leaving the
status tints alone. The tints were built to sit on white; on near-black they
lose their tint entirely and the four levels become four greys.
Both themes must be printable. A reader who prints a dark-theme page and gets a black rectangle has not got a record of their result. The print stylesheet forces a light ground and collapses translucency; Print and export is the contract.
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
What is guaranteed. Both themes are derived together and measured together
against the same floor; hue and status ordering are stable across themes;
color-scheme is set so platform UI follows; nothing about the layout changes
between themes, so a reader switching does not lose their place.
What is yours. Any colour you introduce, including imagery. A photograph or an illustration authored for a light background will usually need a dark-theme counterpart or a scrim. See The contrast floor. Charts you draw yourself need their gridlines and axes re-derived too; a light-theme gridline is invisible on a dark ground.
Related preferences that are not dark mode. prefers-contrast: more and
forced-colors: active are separate requests and dark mode does not satisfy
either. A reader may want dark and increased contrast, and the system has to
compose them. Increased contrast
covers what changes.
A note on light sensitivity. Dark mode is frequently described as the accessible choice. For some readers it is; for others, particularly those with astigmatism, light text on a dark ground is measurably harder to read. That is the reason the system supports both properly rather than picking a side, and the reason neither theme is allowed a lower floor than the other.
Related
- How the engine works has the ladder that both themes are derived from, read from opposite ends.
- Dark mode in the handbook covers the class strategy, the provider and preventing the first-paint flash.
- The ladder explains why elevation changes mechanism between themes, rung by rung.
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.
Colour tokens
The generated reference for every colour token. It carries the naming grammar, both themes, both gamuts, and the measured pair each one belongs to.