← Back
[W] Writing

Dark mode is not light mode with the colours flipped

The quickest dark mode is one line of CSS: filter: invert(1). Light becomes dark, dark becomes light, done.

It looks wrong straight away, and it's worth seeing exactly why. Try the three versions below.

The inverted version is the light version with filter: invert(1) on top. The contrast numbers are computed from the colours actually on screen.

What inverting breaks

Inverting replaces every channel with its opposite. That's a fine rule for numbers and a bad one for an interface, because several things in a light theme only work because they're dark.

Shadows become glows. A shadow is a darker patch under a raised surface. Inverted, it's a lighter patch, so the menu now looks lit from behind.

Hues flip. Inverting blue gives orange. The brand colour is gone. The common patch is invert(1) hue-rotate(180deg), which brings the hue roughly back but fixes nothing else.

Photos turn into negatives. Images were never meant to be themed.

Hierarchy flattens. This one is subtle. Contrast isn't measured on a straight line, so inverting doesn't preserve it. Body text at #18181b on white is 17.7:1, and muted text at #595959 is 7.0:1. Inverted, the body text barely changes while the muted text rises to 8.6:1. The gap between primary and secondary text shrinks, so everything starts to shout at the same volume.

Apple's guidance says it directly: dark mode colours "aren't necessarily inversions of their light counterparts".

Elevation goes up in lightness

On a light background, height is shown with shadow. On a dark background there's almost nothing darker for a shadow to fall on, so shadows stop working.

The fix is to make raised surfaces lighter, as if they're closer to a light above them. Material Design's dark theme did this with a semi-transparent white overlay on a #121212 base: 5% at 1dp of elevation, rising to 16% at 24dp. Material's Android library computes the overlay with a log curve, so the first few steps change the most.

// Material Components: overlay alpha for an elevation in dp.
const alpha = (dp) => (4.5 * Math.log1p(dp) + 2) / 100;
The same elevation three ways, using Material's overlay formula. In dark mode, the shadow-only card disappears into the page at every height.

Apple uses the same idea. In dark mode, iOS has two sets of background colours, base and elevated. Base is dimmer and recedes, elevated is brighter and advances, and the system switches to elevated automatically for things in the foreground, like a sheet or popover.

Material 3 later replaced the overlays with a set of tonal surface colours. The principle didn't change: in dark mode, up means lighter.

Colour has to calm down

A saturated colour that sits comfortably on white is too loud on near-black. Google's dark theme codelab puts it plainly: "More saturated colors tend to visually 'vibrate' against darker backgrounds, making them harder to read."

It's often not even readable. The light theme's blue, #2563eb, is 5.2:1 under white text. As a link on #121212 it's only 3.6:1, below the 4.5:1 that WCAG asks for. So dark themes use a lighter, less saturated version of the accent, and often put dark text on it instead of white.

The background usually isn't pure black either. Material recommends #121212, and its Android docs say dark grey "increases visibility for shadows and also reduces eye strain for light text". Near-black also leaves room below it: you can't make a surface darker than #000, so a pure black page has nowhere to put a sunken element. On some OLED phones there's another reason. Pixels that are fully off can be slow to light up, which shows as smearing when dark content scrolls over black.

Light text looks bolder

Put the same text in white on black and black on white. The white one looks heavier.

This is the irradiation illusion: a light shape on a dark ground looks bigger than the same dark shape on a light ground, partly because light scatters inside the eye. For many people with astigmatism, bright text on black also seems to blur or glow at the edges.

So designed dark themes rarely use pure white text. Material's guidance uses white at 87% opacity for high-emphasis text, and 60% for medium. Some teams go further and lower a variable font's grade axis in dark mode. Grade changes stroke thickness without changing letter widths, so the text gets thinner without reflowing.

Contrast isn't symmetric

The WCAG 2 ratio gives the same number whichever colour is the text. Perception doesn't. APCA, the contrast model proposed for future guidelines, scores #767676 text on white at Lc 72 and white text on #767676 at Lc 77. Same two colours, different readability.

The practical result is that you can't derive one theme from the other by flipping numbers. Each mode needs its own values, checked on their own backgrounds. Apple asks for at least 4.5:1 and suggests aiming for 7:1 for small text, in both appearances.

Making two themes manageable

None of this works if colours are written into components. A designed dark mode means every colour has two values, so components have to ask for a role, not a colour.

TokenLightDark --page#f4f4f5#121212 --surface#ffffff#1e1e1e --surface-raised#ffffff + shadow#2c2c2c --text#18181b#e8e8e8 --text-muted#595959#949494 --accent#2563eb#8ab4f8 --on-accent#ffffff#0b1a33 --border#e4e4e7#2e2e2e
The tokens behind the demo's two designed themes. Raised surfaces stay white in light mode and get lighter in dark. The accent gets lighter and its text flips to dark.

CSS now has the pieces built in. color-scheme tells the browser which themes your page supports, so form controls, scrollbars and default colours follow. light-dark() picks one of two values based on that scheme.

:root {
  color-scheme: light dark;
  --surface: light-dark(#ffffff, #1e1e1e);
  --surface-raised: light-dark(#ffffff, #2c2c2c);
  --text: light-dark(#18181b, #e8e8e8);
  --accent: light-dark(#2563eb, #8ab4f8);
  --on-accent: light-dark(#ffffff, #0b1a33);
}

.menu {
  background: var(--surface-raised);
  box-shadow: 0 8px 24px light-dark(rgb(0 0 0 / 0.14), rgb(0 0 0 / 0.5));
}

light-dark() has been Baseline since May 2024: Chrome 123, Firefox 120 and Safari 17.5. It only works when color-scheme allows both. Chrome 150, Firefox 150 and Safari 27 also accept two images, which helps with the photo problem, alongside <picture> with a prefers-color-scheme media query.

Dark mode is a second design, not a filter. The work is deciding what each role should be in each mode, once, and then never letting a component pick a colour on its own.

Next