---
title: "Dark mode"
description: "Dark is re-derived from the same ladder rather than inverted, and there are seven invariants it has to preserve on the way."
url: "https://opsinjs.pensievelabs.org/foundations/colour/dark-mode"
source: "https://opsinjs.pensievelabs.org/foundations/colour/dark-mode.md"
section: "Foundations"
kind: "foundation"
reviewed: "2026-09-02"
reviewer: "design"
aliases: ["dark mode derivation", "light first", "colour scheme", "night mode", "inversion"]
---

> 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]

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](../../handbook/dark-mode.mdx). This page is about
what the colours have to mean.

## How it works [#how-it-works]

### Seven invariants [#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](./contrast-and-apca.mdx).

**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](../materials/the-ladder.mdx).

**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 [#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 [#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 [#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.

<Todo>
  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.
</Todo>

**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](../motion/reduced-motion.mdx).

<DoDont>
  <DoDont.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.
  </DoDont.Do>

  <DoDont.Dont>
    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.
  </DoDont.Dont>
</DoDont>

**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](../../accessibility/print-and-export.mdx) is the contract.

## Tokens [#tokens]

<TokenTable scope="color" />

## Accessibility impact [#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](../materials/the-contrast-floor.mdx). 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](../../accessibility/increased-contrast.mdx)
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 [#related]

* [How the engine works](./how-the-engine-works.mdx) has the ladder that both
  themes are derived from, read from opposite ends.
* [Dark mode in the handbook](../../handbook/dark-mode.mdx) covers the class
  strategy, the provider and preventing the first-paint flash.
* [The ladder](../materials/the-ladder.mdx) explains why elevation changes
  mechanism between themes, rung by rung.
