Dark Mode Color Palette: How to Design One (Step by Step)
How to design a dark mode color palette that is easy on the eyes — surfaces, desaturation, elevation and accessible contrast, with CSS.

On this page▾
A good dark mode color palette is not your light theme with the colors flipped. Dark interfaces change how the eye reads contrast, depth, and saturation, so the values that looked crisp on white can glow, vibrate, or disappear on a dark surface. This guide walks through building a dark theme from the ground up — surfaces, elevation, desaturated accents, and accessible text — and ends with the CSS variables and OKLCH to ship it.
Why dark mode is not just "invert everything"
The naive approach — swap #ffffff for #000000, flip your grays, call it done — fails for three reasons.
First, contrast inverts in feel, not just in math. Pure white on pure black is technically the highest-contrast pair possible (~21:1), but at that extreme the letters bloom and smear — halation, which is worse for readers with astigmatism. Dark mode needs softer extremes, not harder ones.
Second, saturation reads hotter on dark. A brand color that looks calm on white can buzz against a dark field, so you must actively tone accents down.
Third, depth cues reverse. In light mode, elevated elements cast shadows and look closer. On a dark background a shadow is nearly invisible, so you signal elevation by making raised surfaces lighter, not darker. Inverting your palette throws all of this away. Build the dark theme as its own system instead.
Start with dark gray surfaces, not pure black
Your base background sets the tone for everything layered on top. Resist #000000. A true-black canvas gives you nowhere to go for shadows, makes elevated cards look like floating rectangles, and maximizes halation under text.
The widely adopted baseline is #121212 — Material Design's dark surface. It reads unmistakably as "dark mode" while leaving room to stack lighter surfaces above it.
| Base | Hex | OKLCH lightness | Feel |
|---|---|---|---|
| Near-black | #0a0a0a | 0.14 | Very dark, OLED-leaning |
| Recommended | #121212 | 0.18 | Material baseline, safe default |
| Soft dark | #1a1a1a | 0.22 | Gentler, reads more "gray" |
| Cool dark (zinc-900) | #18181b | 0.21 | Subtle cool tint, modern |
A tiny tint can make a dark theme feel intentional rather than muddy. Tailwind's zinc and slate ramps add a faint cool cast that many product UIs use; the warm equivalent is stone. Keep the tint subtle — a couple of degrees of hue and a chroma under ~0.01 in OKLCH. If you want a pure neutral, the gray spectrum page is a quick reference, and the case for avoiding true black is covered there too.
Build an elevation system with surface layers
In dark mode, elevation is communicated by lightness: the higher a surface "floats," the lighter it gets. The cleanest way to generate a consistent ramp is to composite a white overlay at increasing opacity over your #121212 base. Each step adds a predictable amount of light, so the layers always feel related.
| Token | White overlay | Resolved hex | Use |
|---|---|---|---|
--surface-0 | 0% | #121212 | App background |
--surface-1 | 5% | #1e1e1e | Cards, panels |
--surface-2 | 8% | #252525 | Popovers, raised cards |
--surface-3 | 11% | #2c2c2c | Menus, dialogs |
--surface-4 | 14% | #333333 | Tooltips, top layer |
Those hex values are exactly what a white overlay resolves to over #121212 — rgba(255,255,255,0.05) composites to #1e1e1e. Bake them in as static tokens (recommended) or apply them live:
.surface-1 {
background-color: #121212;
background-image: linear-gradient(rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.05));
}
Keep the steps small and evenly spaced: too far apart and a card on a panel looks like a different theme; too close and the hierarchy disappears. Five layers covers almost every UI. To extend the ramp into hover and pressed states, generate the whole neutral scale in the Shade & Tint Generator and pull the steps you need.
Desaturate your accents (avoid optical vibration)
This is the step most "just invert it" palettes skip, and it is what separates a polished dark theme from an amateur one.
A saturated accent that looks confident on white can vibrate on dark — the edges shimmer and the color feels like it is straining. The fix is to lighten the accent and pull some chroma out of it. A lighter, slightly muted color holds its identity without the optical buzz, and as a bonus it gains contrast against the dark surface.
Here is the same blue moving down Tailwind's scale, with its contrast measured against #121212:
| Color | Hex | OKLCH | Contrast on #121212 |
|---|---|---|---|
| blue-600 (light primary) | #2563eb | oklch(0.55 0.215 263) | 3.62:1 — fails |
| blue-500 | #3b82f6 | oklch(0.62 0.188 260) | 5.09:1 — AA |
| blue-400 (dark primary) | #60a5fa | oklch(0.71 0.143 255) | 7.37:1 — AAA |
| blue-300 | #93c5fd | oklch(0.81 0.096 252) | 10.39:1 — AAA |
Notice the chroma falling as the color climbs — 0.215 down to 0.096 — while lightness rises. That is desaturation and lightening working together. Your light-mode #2563eb would actually fail contrast on the dark surface; promoting it to #60a5fa both fixes legibility and kills the vibration.
A practical rule: in OKLCH, raise L by about 0.15–0.20 and cut C by roughly a third for the dark variant.
Keep your brand color recognizable across light/dark
Desaturating is a balancing act: push too far and the brand blue becomes a generic pale blue. The trick is to anchor on hue. Because OKLCH separates hue from lightness and chroma, you can move L and C freely while holding H, and the color stays recognizably "your blue."
Walking the example above in OKLCH:
- Light:
oklch(0.546 0.215 263)—#2563eb - Dark:
oklch(0.714 0.143 255)—#60a5fa
That is roughly +0.17 lightness, −0.07 chroma, and an 8° hue drift — close enough that both read as the same brand color, one tuned for white and one for #121212. Hold the hue, move the other two axes, and check the result. If your brand color is highly saturated (a vivid red or magenta), expect to cut more chroma; muted earth tones may need almost none.
For a multi-color brand — primary, secondary, semantic success/warning/error — run the whole set through the same transform so they stay coordinated. The Brand Color Kit builds a matched light-and-dark set from a single seed color, which saves doing this by hand for every token. (For the mechanics of why hue stays stable in OKLCH, see what OKLCH is and why it matters.)
Text & contrast in dark mode (WCAG, dimmed whites)
Body text on dark should almost never be pure white. #ffffff on #121212 hits ~18.7:1 — past AAA, but into halation territory where the text glows. Dim it.
A three-tier text scale on #121212 covers most needs:
| Role | Hex | Contrast on #121212 | WCAG |
|---|---|---|---|
| Primary text | #e0e0e0 | 14.19:1 | AAA |
| Secondary text | #a1a1aa | 7.31:1 | AAA |
| Muted / disabled | #71717a | 3.88:1 | Large text only |
Material's equivalents express the same idea as opacity: high-emphasis text is white at 87% (rgba(255,255,255,0.87), which resolves to about #e0e0e0), medium at 60%, disabled at 38%. Either approach is fine — solid hex tokens are easier to reason about and test.
The catch developers miss: check contrast against the surface the text actually sits on, not the base background. Text on an elevated --surface-3 (#2c2c2c) has less contrast than the same text on #121212. Your muted gray that passed on the background can quietly fail on a raised card:
| Pair | Contrast | Verdict |
|---|---|---|
#e0e0e0 on #2c2c2c | 10.58:1 | Comfortable |
#a1a1aa on #2c2c2c | 5.45:1 | Passes AA body |
#60a5fa on #1e1e1e | 6.56:1 | Passes AA body |
Target 4.5:1 for body text, 3:1 for large text and UI components (borders, icons, focus rings). Run your real pairs through the Color Contrast Checker for every surface, and read how to choose accessible color combinations for the full WCAG breakdown.
Implementing with CSS variables & OKLCH (prefers-color-scheme)
Define your tokens once as CSS custom properties and let prefers-color-scheme swap them. The color-scheme property is a freebie that themes native scrollbars, form controls, and the page background to match.
:root {
color-scheme: light;
--bg: #ffffff;
--surface-1: #f4f4f5;
--surface-2: #e4e4e7;
--text: #18181b;
--text-muted: #52525b;
--border: #e4e4e7;
--primary: #2563eb; /* oklch(0.55 0.215 263) */
--primary-foreground: #ffffff;
}
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
--bg: #121212;
--surface-1: #1e1e1e;
--surface-2: #252525;
--surface-3: #2c2c2c;
--text: #e0e0e0; /* dimmed, not #ffffff */
--text-muted: #a1a1aa;
--border: rgba(255, 255, 255, 0.09);
--primary: #60a5fa; /* lighter + lower chroma */
--primary-foreground: #121212;
}
}
A few details worth noting:
--primary-foregroundflips. On the light accent button, dark text reads best —#121212on#60a5faclears 7.37:1.- Borders use a translucent white (
rgba(255,255,255,0.09)) rather than a fixed gray, so they keep the right contrast as they sit on different surfaces.
If you offer a manual toggle, add a data-theme attribute that overrides the OS preference, and respect the system default when no choice is set:
[data-theme="dark"] {
color-scheme: dark;
--bg: #121212;
--text: #e0e0e0;
--primary: #60a5fa;
/* …rest of the dark tokens… */
}
To go OKLCH-native, store the tokens as oklch() and let each theme set its own lightness — equal L steps look evenly spaced, which is exactly what HSL fails to deliver:
:root { --primary: oklch(0.55 0.215 263); } /* light */
@media (prefers-color-scheme: dark) {
:root { --primary: oklch(0.71 0.143 255); } /* dark: +L, −C, ~same hue */
}
OKLCH has broad browser support today; for older engines, list a hex declaration first and let the oklch() line override it.
Generating the palette fast (ramp + tokens)
You do not have to hand-tune every value. A fast, repeatable workflow:
- Seed your brand color. Drop it into the Brand Color Kit to get a coordinated primary, secondary, and neutral set, light and dark.
- Build the ramps. Generate the neutral surface scale (
#121212up) and each accent's 50–950 steps in the Shade & Tint Generator. For dark mode you mostly use the lighter half of accent ramps and the darker half of the neutral ramp. - Export the formats. Run each token through the Color Format Converter to grab the OKLCH, hex, and
rgb()values, plus ready-made--custom-propertyand Tailwind snippets. - Verify, then ship. Contrast-check the real pairs on each surface before committing the tokens.
If you are on Tailwind, map these onto the framework's scale (bg-zinc-900, text-zinc-200, dark:bg-blue-400) instead of arbitrary values — the Tailwind color palette guide shows how to wire custom tokens into the config.
Testing your dark theme (contrast, OLED, real devices)
Dark mode hides its failures until you look closely, so test deliberately.
- Contrast pass. Walk every text/background and UI/background pair through a checker — including text on elevated surfaces, disabled states, and placeholder text, which are the usual offenders.
- Halation check. View long body text in a dark room. If white text shimmers, dim it a step (
#e0e0e0→#d4d4d4) or lighten the background slightly. - OLED behavior. On OLED screens,
#000000pixels switch fully off, so true-black themes can smear on scroll and show a hard edge where black meets a dark surface.#121212avoids both; offer a "pure black" option only if you specifically want OLED power savings. - Saturated accents in context. Put your accent next to large dark areas, not just on a swatch. Vibration only shows up at scale.
- Real devices and ambient light. A palette tuned on a bright studio monitor can look washed out on a phone at night. Check both extremes.
- The OS-flip. Toggle the system theme live and confirm nothing flashes, gets stuck, or loses contrast in the transition.
Get the surfaces, elevation, desaturation, and contrast right, and your dark theme will feel like a first-class design — not an inverted afterthought.
Frequently asked questions
Should dark mode use pure black?
Usually not. Pure black against bright text creates harsh halation and removes the subtle shadows you need to show depth. Start from a dark gray like #121212 and reserve true black for OLED-specific themes.
What is a good dark mode background color?
A very dark, near-neutral gray around #121212 to #1a1a1a (or Tailwind zinc-900, #18181b) works well. It is dark enough to read as dark mode but light enough to layer elevated surfaces on top.
How do I pick accent colors for dark mode?
Take your light-mode brand color and make it lighter and slightly less saturated so it stays vivid without vibrating. In OKLCH that means raising L and lowering C while keeping the hue, then checking it clears 4.5:1 on your darkest surface.
Why do saturated colors vibrate on dark backgrounds?
Highly saturated colors sit far from a dark background in both brightness and wavelength, and the eye struggles to focus both at once (an effect called chromostereopsis). Lowering chroma and raising lightness settles the color down.
How do I meet WCAG contrast in dark mode?
Check every text and UI color against the actual surface it sits on, not just the base background, and target 4.5:1 for body text and 3:1 for large text and UI elements. Dim white text to around #e0e0e0 and lighten accents until they pass.
