Huely color system builder

Huely/Guides/Dark mode palettes

Dark mode color palettes

A dark theme is not a light theme with the lightness flipped. Every assumption the light theme made about elevation, saturation and contrast direction reverses, and the ones that are not reconsidered are the ones that look wrong.

Published · Last reviewed

Why inversion fails

Inverting a theme — mapping every lightness value L to 1 - L — gets one thing right and four things wrong. It correctly swaps the background and text. It then turns every brand color into a color of a different hue, makes every shadow a glow, produces contrast ratios nobody has checked, and destroys the relationship between elevation and lightness that the light theme depended on.

The right model is that you have one set of tonal ramps and two sets of role assignments. In light mode, background reads from the 50 end and text from the 900 end. In dark mode, both read from the other end. The ramps do not change; what each role points at does.

Elevation is lightness, not shadow

In a light interface, something raised above the page casts a shadow. In a dark interface a shadow is nearly invisible — there is not enough range below the background for it to fall into. The convention that replaced it is simple: higher means lighter.

LayerLight themeDark theme
Page background#FFFFFF#0E0E12
Card / surface#F7F8FA + shadow#17171E
Raised panel, popover#FFFFFF + larger shadow#1F1F29
Modal, tooltip#FFFFFF + largest shadow#2A2A36

Three or four levels is enough. Keep the steps small — 4 to 8 points of OKLCH lightness — because at the dark end of the ramp a small numeric difference is a large perceived one. Shadows still have a job in dark mode, but as a subtle depth cue underneath the lightness change, not instead of it.

Saturated colors vibrate

A highly saturated color against a dark background appears to glow and, at small sizes, to shimmer at its edges. The effect is worst for blues and violets, and it makes body text in a saturated color genuinely tiring to read.

Three adjustments, in order of how often they are needed:

Near-black, not black

#000000 with #FFFFFF text is 21:1. That is the maximum the scale allows and it is more than anyone wants for reading — it produces halation, the smearing effect where light text bleeds into the dark around it, which is especially pronounced for readers with astigmatism.

A background around #101014 to #16161C with text around #E8E8EE gives roughly 15:1, which is comfortable and still far above AA. It also leaves headroom: pure black has nothing below it, so there is no color available for a recessed area, an inset field, or a scrollbar track.

Pure black is a reasonable choice in two cases — OLED battery savings on mobile, and media viewers where the content should be the only lit thing on screen. Even there, the surfaces above the background should not be black.

Contrast behaves differently

The WCAG ratio is symmetric — swapping foreground and background gives the same number — but the perceptual experience is not. Light text on a dark background reads as slightly heavier than the same pair reversed, which is why dark themes often want a lighter font weight than their light counterparts.

Two things fail more often in dark mode than in light mode. Muted text, which designers set by moving toward the background: in light mode that means lighter grey, in dark mode darker grey, and dark grey on near-black runs out of room fast. And borders, which have less range to work with below the surface color than above it — often the answer in dark mode is a border lighter than the surface rather than darker.

Check a dark-mode pair

Structuring the tokens

Keep primitives theme-independent and let semantic tokens point at different primitives per theme. That way there is one source of color and two mappings, and a component never knows which theme it is in.

:root {
  /* primitives — identical in both themes */
  --blue-100: #d8e4fb;
  --blue-400: #6f9ef0;
  --blue-600: #1f5fd0;
  --grey-50:  #f7f8fa;
  --grey-900: #17171e;
  --grey-950: #0e0e12;

  /* semantics — light theme */
  --bg: #ffffff;
  --surface: var(--grey-50);
  --text: var(--grey-950);
  --primary: var(--blue-600);
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg: var(--grey-950);
    --surface: var(--grey-900);
    --text: #e8e8ee;
    --primary: var(--blue-400);
  }
}

:root[data-theme="dark"] { /* same overrides, for an explicit toggle */ }

Two details that save trouble later. Declare the complete light palette on bare :root so no token exists only inside a media query. And support all three states — light, dark, and system — because a user who has explicitly chosen light on a device set to dark expects to get light.

A worked pair

RoleLightRatioDarkRatio
Background#FFFFFF#0E0E12
Surface#F4F5F7#17171E
Text on bg#16181D17.6:1#E8E8EE15.4:1
Muted on surface#5B60706.4:1#9A9AAE6.9:1
Border on surface#8A90A03.1:1#3A3A481.6:1
Primary on bg#1F5FD05.9:1#6F9EF07.3:1

The border row is the interesting one: #3A3A48 on #17171E is 1.6:1, fine as a decorative divider and failing 1.4.11 if it is the only thing that makes an input look like an input. Dark themes need a second, lighter border token for that job — around #585868 here — and light themes usually do not notice the distinction because their decorative border is already dark enough.

What goes wrong

  1. Inverting. See above.
  2. Pure black plus pure white. 21:1 is glare, not clarity.
  3. Reusing the light-mode brand color unchanged. It either fails contrast or vibrates.
  4. Shadows as the only elevation cue. They are nearly invisible on a dark ground.
  5. One border token. Decorative and functional borders need different values in dark mode.
  6. Only testing dark mode. The theme people actually use is whichever their OS is set to; both need the same scrutiny.
  7. Flashing on load. Resolve the theme before first paint, or the page flashes the wrong one.

Checklist

Frequently asked questions

Should a dark theme use pure black?

Usually not. Pure black against near-white text is 21:1, which reads as glare rather than as clarity, and on OLED the transition between a black background and a lit surface can smear. A near-black around #121216 keeps the contrast in a comfortable range and leaves room for darker elements below it. Pure black is defensible for OLED battery life and for media viewers where the content should be the only lit thing.

Can I just invert my light theme?

No. Inverting maps your background to your text and vice versa, which is roughly right, but it also inverts every brand color into its complement and produces contrast ratios nobody checked. Dark mode is a second set of role assignments drawn from the same ramps, read from the other end.

Why does my brand color look wrong on a dark background?

Two effects. Saturated colors appear to glow and vibrate against dark backgrounds, and a color that had enough contrast against white often has too little against near-black. The fix is to move up the ramp — a lighter, slightly less saturated step of the same hue — rather than to keep the exact brand value.

Do I need to check contrast separately for dark mode?

Yes. The contrast ratio is a property of a pair of colors, and dark mode is a different pair. Passing in light mode says nothing about dark mode, and in practice the failures cluster in different places: muted text and borders in light mode, saturated accents and status colors in dark mode.

Next

Contrast checkerMeasure the dark-theme pairs — they are different pairs. Color design tokensThe primitive and semantic split this guide relies on. Palette builderBuild the ramps both themes read from. Accessible color palettesThe pairings each theme has to pass.