ChromaWell

Blog

Dark Mode Color Palette Guide

Dark mode isn't light mode with colors inverted — elevation, saturation, and contrast all behave differently once the surface is dark. Here's what actually changes.

The most common dark-mode bug isn't a missing token — it's treating dark mode as "invert the light palette" when the underlying physics of a dark surface actually calls for different decisions in at least three places: elevation, saturation, and contrast headroom.

Surfaces get lighter as they rise, not darker

In a light-mode UI, elevated surfaces (a modal over a page, a dropdown over a list) are conventionally indicated with a drop shadow — a darker, blurred region beneath the elevated element. On a dark background, a drop shadow is nearly invisible, because there's very little darkness left to add. The convention that replaced it, popularized by Material Design's dark theme guidance, is the opposite: elevated surfaces get progressively lighter, not darker, as they rise. A base dark background might sit at roughly 8–10% lightness; a card floating above it moves to 12–14%; a modal above that to 16–18%, and so on. This isn't arbitrary — it mimics how a light source would actually illuminate objects closer to the "front" in physical space, and it gives users a genuine, working depth cue that a shadow can't provide once the background is already dark.

Full black backgrounds are usually the wrong choice

A pure black (#000000) background is tempting — maximum battery savings on OLED screens, maximum contrast against white text — but it creates two real problems. First, it removes any room to show elevation through lightening, since there's no way to get "lighter than pure black" without immediately becoming a visibly gray surface, which can look like a mistake rather than a deliberate step if the ramp isn't planned. Second, pure black against pure white text produces the maximum possible 21:1 contrast ratio, which sounds like a good thing but can actually cause a real perceptual effect called "halation" for some users — bright text on a fully black background can appear to visually vibrate or bloom, especially for users with astigmatism. Most production dark themes use a dark charcoal or near-black (often in the 5–12% lightness range, not literal 0%) as the base surface for exactly these reasons.

Saturated colors need to be desaturated and lightened for dark surfaces

A saturated brand blue that looks vivid and correct on a white background frequently looks harsh, overly glowing, or slightly garish on a dark background — the same hex value reads differently depending on what surrounds it, a real perceptual phenomenon (simultaneous contrast) rather than a rendering inconsistency. The common fix is to desaturate slightly and raise lightness for the same semantic role on a dark surface, rather than reusing the exact light-mode hex. In a well-built design token system, this is exactly what the semantic-to-primitive remapping under a [data-theme="dark"] scope is for — the semantic token name (--color-action-primary) stays the same, but it points at a different, dark-surface-appropriate primitive step.

Contrast math doesn't change, but the safe zone shifts

The WCAG contrast formula itself is identical for dark and light surfaces — see WCAG contrast explained for the actual relative-luminance math — but the practical range of colors that clear 4.5:1 shifts substantially. A gray that comfortably clears AA against white can fail badly against a dark navy background, and vice versa; there is no single "safe gray" that works against both. Every text/background pairing needs independent verification with the contrast checker for its own specific surface — reusing a light-mode-tested gray on a dark surface without re-checking it is one of the most common dark-mode accessibility regressions in real products.

Building both from one source, not two unrelated palettes

The pattern that avoids maintaining two disconnected palettes is generating both light and dark surface variants from the same primitive ramp, using different slices of it. If your primitive blue ramp runs from a near-white 50 step to a near-black 900 step, light mode might use step 500 for primary action text and step 50 for background, while dark mode uses step 300 (lighter, more desaturated-feeling at that end of a typical ramp) for primary action and step 900 for background. Generate the full ramp once with the shades, tints & tones generator, and pick light/dark slices from the same source rather than hand-tuning two entirely separate sets of colors that can drift apart over time.

Testing checklist specific to dark mode

  • Every text/background pairing re-checked against the actual dark surface color, not assumed safe because it passed on light mode.
  • Elevation communicated through lightness steps, not shadows, on any surface darker than roughly 20% lightness.
  • Saturated brand colors re-tuned (usually desaturated, sometimes lightened) for dark surfaces rather than reused verbatim from light mode.
  • Pure black avoided as the base surface color unless there's a specific OLED battery-life requirement driving that choice, and even then, text elements kept off pure white to reduce halation risk.
  • Images, icons, and illustrations checked for whether they were designed assuming a light background — many need dark-mode-specific variants, particularly anything with white or very light backgrounds baked into the asset itself.

Dark mode is a second, deliberately-designed palette variant, not a CSS filter applied to the first one — treating it that way from the start avoids most of the recurring bugs above.

Naming a dark-mode surface scale, and how it plugs into CSS custom properties

A practical implementation pattern layers a small elevation scale on top of the existing dark-mode semantic tokens rather than inventing a parallel system: --surface-0 (the base background), --surface-1 (first elevation step, e.g. a card), --surface-2 (second step, e.g. a modal), each a fixed lightness increment lighter than the last. Combined with custom properties, switching the whole scale for a theme is a single block:

[data-theme="dark"] {
  --surface-0: oklch(12% 0.01 259);
  --surface-1: oklch(16% 0.01 259);
  --surface-2: oklch(20% 0.01 259);
}

Using OKLCH for the elevation scale specifically (rather than HSL) keeps the lightness steps looking evenly spaced regardless of which hue the surface tint leans toward — see what OKLCH is and why it matters for why HSL's lightness channel doesn't guarantee that evenness. The full custom-property theming mechanics, including avoiding a flash of the wrong theme before JavaScript resolves the user's preference, are covered in CSS variables for theming.

Testing against real content, not empty mockup frames

One more practical gap: dark-mode palettes often get approved against clean, mostly-empty mockup frames and only reveal problems once real content — dense tables, embedded images with white backgrounds, syntax-highlighted code blocks, user-uploaded photos — is dropped in. Test the elevation and contrast decisions above against a screen that's actually full of realistic content before signing off, not just the three or four hero screens that happened to be in the design file.

Get new tools & color guides by email

Occasional emails when a new tool, dataset, or in-depth guide ships. No spam, unsubscribe anytime.