Designing Dark Mode Color Palettes: A Complete Developer's Guide

Designing Dark Mode Color Palettes: A Complete Developer's Guide

April 22, 2026
12 min read

What You'll Learn

  • Why dark mode is fundamentally different from color inversion
  • How to use surface elevation to create visual depth
  • WCAG contrast rules specifically for dark UI text
  • How to define semantic color tokens for theming
  • How to adapt brand colors for dark backgrounds
  • A complete CSS implementation with custom properties

1. Why Dark Mode Is More Than Just Inverting Colors

A common misconception among developers starting out with dark mode is that it's simply a matter of flipping the color scale - making backgrounds dark and text light. Hit filter: invert(1) on the body and you're done, right? Wrong. That approach creates garish, inconsistent experiences that actually strain the eyes more than a well-lit screen.

True dark mode design is a deliberate system. It accounts for how the human eye perceives luminance differently in low-light environments, how UI elements need to communicate depth without relying on drop shadows the same way they do in light mode, and how colors that work brilliantly on a white canvas can feel overwhelming or washed-out on a dark one.

Consider the difference: in light mode, depth is communicated by shadows - a card sits above a background because it has a drop shadow. In dark mode, shadows on dark surfaces are nearly invisible, so depth must instead be communicated through surface color itself. The card appears lighter than the background, not by a shadow, but by its surface tone.

Light Mode vs. Dark Mode: Key Differences

Light Mode Logic

  • Depth via drop shadows
  • Backgrounds near white (#FFFFFF, #F8F9FA)
  • Text near black (#1A1A1A)
  • Brand colors show full saturation
  • Color stands out against neutral backgrounds

Dark Mode Logic

  • Depth via lighter surface levels
  • Backgrounds near dark gray (#121212, #1E1E1E)
  • Text near off-white (#E8E8F0)
  • Brand colors may need lightening/desaturating
  • Glows and subtle gradients enhance depth

Google's Material Design guidelines recommend a background of #121212 as the base dark surface - not pure black. Pure black (#000000) creates a harsh contrast that can cause eye strain, and it doesn't allow for the elevation system to function properly because there's nowhere lower to go.

2. Surface Levels and Elevation in Dark Mode

Material Design's elevation model translates beautifully to dark mode. The core principle: the higher an element is in the z-stack, the lighter its surface color should be. This creates a perceptual sense of elevation without relying on shadows.

Each elevation level adds a white overlay with increasing opacity. At elevation 0 (the background), the surface is the base dark color. At elevation 1 (cards, panels), a 5% white overlay is applied. At elevation 8 (modals, popovers), a 12% overlay. At elevation 24 (dialogs at the very top), up to 16%.

Dark Mode Elevation Scale

Elevation 0 (Background)

#121212 - Base background

Elevation 1 (Cards)

#1E1E1E - 5% white overlay

Elevation 2 (Navigation)

#222222 - 7% white overlay

Elevation 3 (Dropdowns)

#272727 - 8% white overlay

Elevation 4 (Modals)

#2C2C2C - 9% white overlay

Elevation 8 (Popovers)

#2E2E2E - 11% white overlay

Elevation 16 (Snackbars)

#333333 - 14% white overlay

Elevation 24 (Dialogs)

#383838 - 16% white overlay

In practice, you don't need all 8 levels. Most interfaces get by with 3–4 distinct surface levels. The key is consistency: once you define your elevation system, apply it uniformly so users can intuitively understand that lighter = closer.

3. Text Contrast Rules for Dark Mode

The WCAG (Web Content Accessibility Guidelines) require a minimum contrast ratio of 4.5:1 for normal text (AA compliance) and 7:1 for AAA compliance. For large text (18px+ bold or 24px+ regular), AA requires just 3:1. These rules apply equally to dark mode - but the challenge is different.

On a light background, near-black text (#1A1A1A on #FFFFFF) scores an enormous 18.5:1 ratio - far exceeding even AAA requirements. On a dark background, you might assume that pure white on pure black (#FFFFFF on #000000) is perfect. And mathematically it is - 21:1 is the maximum possible ratio. But pure white text on pure black causes a "halation" effect where the extreme contrast makes the letters appear to glow, creating eye strain during extended reading.

Recommended Dark Mode Text Colors

#E8E8F0

Primary Text (~15:1)

Main body text - off-white with a subtle cool tint, contrast ~15:1 on #121212

#A0A0B0

Secondary Text (~7:1)

Captions, metadata, labels - readable but clearly subordinate

#606070

Disabled Text (~3:1)

Disabled states, placeholder text

#4A4A5A

Hint Text (<2:1)

Very subtle hints - only for decorative contexts, not real content

The key insight: never use pure #FFFFFF as your primary body text in dark mode. Instead, use a slightly warm or cool off-white like #E8E8F0 or #F0EFE9. This subtle softening reduces halation while maintaining excellent contrast ratios well above AA requirements.

Similarly, avoid using secondary text that's too dim - many designers make the mistake of using mid-gray text on dark backgrounds, producing contrast ratios below 3:1 that fail accessibility standards entirely.

4. Semantic Color Tokens for Dark Mode

Hard-coding color values like #1E1E1E directly into your components is a recipe for a maintenance nightmare. Instead, the modern approach is to define semantic color tokens - named variables that describe the role of a color, not its specific value.

The distinction: a primitive token is gray-900: #121212. A semantic token is background-base: var(--gray-900). The component uses background-base, and when you switch to dark mode, you only change what background-base points to - the component itself needs no modification.

Core Semantic Token Categories

Background Tokens

  • --bg-base - Page background
  • --bg-surface - Cards, panels
  • --bg-overlay - Modals, dialogs
  • --bg-sunken - Inputs, code blocks

Text Tokens

  • --text-primary - Headings, body
  • --text-secondary - Captions, labels
  • --text-disabled - Disabled states
  • --text-inverse - Text on colored buttons

Border Tokens

  • --border-default - Standard dividers
  • --border-strong - Emphasized borders
  • --border-focus - Focus rings

Brand / Interactive Tokens

  • --color-primary - Primary actions
  • --color-primary-hover - Hover state
  • --color-danger - Errors, destructive
  • --color-success - Confirmations

This token architecture means you can ship a complete dark mode by simply defining a new set of values for each semantic token under a .dark class or @media (prefers-color-scheme: dark) rule. Every component automatically inherits the correct dark values.

5. Handling Brand Colors in Dark Mode

Brand colors present a unique challenge when transitioning to dark mode. A vivid indigo that looks sharp on white (#4F46E5 on #FFFFFF) can feel oppressively heavy and visually "bleed" when placed on a dark background. The solution is to shift your brand color toward a lighter, slightly more saturated variant for dark mode.

The general rule: for dark backgrounds, use a lighter tint of your brand color (around the 300–400 shade in a Tailwind-style scale) as your interactive/accent color. Reserve the 600–700 shades for backgrounds and containers, and use them sparingly.

Brand Color Adaptation: Indigo Example

Light Mode - Button Primary

#4F46E5

Dark Mode - Button Primary (lighter, same energy)

#818CF8

Dark Mode - Subtle Background / Container

#312E81

Notice how #818CF8 (indigo-400) reads clearly against #121212 while still feeling on-brand. The original #4F46E5 (indigo-600) barely passes 4.5:1 contrast on dark backgrounds and feels heavy.

For status colors - success green, danger red, warning yellow - apply the same principle. Use lighter, less saturated tones in dark mode. A full-saturation red (#EF4444) works as a danger state in light mode, but #FCA5A5 (red-300) communicates the same urgency on dark backgrounds while maintaining comfortable contrast.

6. A Complete Dark Mode Palette Example

Here's a production-ready dark mode palette with named tokens and specific hex values. This system is based on a cool-neutral dark gray with an indigo brand accent:

Complete Dark Mode System Palette

Backgrounds

--bg-base

#0F0F13 - Page background

--bg-surface

#1A1A24 - Cards, panels (elev. 1)

--bg-overlay

#24243A - Modals, dropdowns (elev. 4)

--bg-sunken

#0A0A0E - Code blocks, inputs

Typography

--text-primary

#E8E8F0 - Headings, body copy

--text-secondary

#9898B0 - Captions, meta

--text-disabled

#55556A - Disabled elements

--text-link

#818CF8 - Links, inline actions

Brand & Interactive

--color-primary

#6366F1 - Primary buttons, CTAs

--color-primary-hover

#818CF8 - Hover state

--color-success

#86EFAC - Success states (green-300)

--color-danger

#FCA5A5 - Error states (red-300)

--color-warning

#FCD34D - Warning states (yellow-300)

--color-info

#93C5FD - Info states (blue-300)

Borders

--border-default

#2A2A3E - Dividers, card outlines

--border-strong

#3E3E58 - Emphasized separators

--border-focus

#6366F1 - Focus ring

7. CSS Implementation with Custom Properties

CSS custom properties (variables) are the best way to implement a dual-mode color system. Define all your light mode values under :root, then override them under a .dark class or @media (prefers-color-scheme: dark). Every element in the page automatically recomputes its colors.

/* === LIGHT MODE DEFAULTS === */
:root {
  /* Backgrounds */
  --bg-base:        #FFFFFF;
  --bg-surface:     #F8F9FA;
  --bg-overlay:     #FFFFFF;
  --bg-sunken:      #F1F3F5;

  /* Typography */
  --text-primary:   #1A1A2E;
  --text-secondary: #6B7280;
  --text-disabled:  #9CA3AF;
  --text-link:      #4F46E5;

  /* Brand */
  --color-primary:       #4F46E5;
  --color-primary-hover: #4338CA;
  --color-success:       #16A34A;
  --color-danger:        #DC2626;
  --color-warning:       #D97706;
  --color-info:          #2563EB;

  /* Borders */
  --border-default: #E5E7EB;
  --border-strong:  #D1D5DB;
  --border-focus:   #4F46E5;
}

/* === DARK MODE OVERRIDE === */
.dark {
  /* Backgrounds */
  --bg-base:        #0F0F13;
  --bg-surface:     #1A1A24;
  --bg-overlay:     #24243A;
  --bg-sunken:      #0A0A0E;

  /* Typography */
  --text-primary:   #E8E8F0;
  --text-secondary: #9898B0;
  --text-disabled:  #55556A;
  --text-link:      #818CF8;

  /* Brand (lighter tints for dark bg) */
  --color-primary:       #6366F1;
  --color-primary-hover: #818CF8;
  --color-success:       #86EFAC;
  --color-danger:        #FCA5A5;
  --color-warning:       #FCD34D;
  --color-info:          #93C5FD;

  /* Borders */
  --border-default: #2A2A3E;
  --border-strong:  #3E3E58;
  --border-focus:   #6366F1;
}

/* === SYSTEM PREFERENCE FALLBACK === */
@media (prefers-color-scheme: dark) {
  :root:not(.light) {
    --bg-base:        #0F0F13;
    --bg-surface:     #1A1A24;
    --text-primary:   #E8E8F0;
    /* ... rest of dark values ... */
  }
}

/* === USAGE IN COMPONENTS === */
.card {
  background-color: var(--bg-surface);
  border: 1px solid var(--border-default);
  color: var(--text-primary);
}

.button-primary {
  background-color: var(--color-primary);
  color: #FFFFFF;
}

.button-primary:hover {
  background-color: var(--color-primary-hover);
}

Notice that components never reference a raw color value - only token names. When you toggle the .dark class on your <html> element (common in React with a theme context), every component automatically receives the correct values. No component-level conditional styling needed.

8. Key Takeaways

  • 01Never invert: Dark mode requires a thoughtful redesign of your color system, not a CSS filter trick.
  • 02Use elevation via lightness: Higher z-level elements get lighter surface colors to communicate depth.
  • 03Avoid pure white text: Use off-white (#E8E8F0) to prevent halation and eye strain during extended reading.
  • 04Define semantic tokens: Components should reference role-based tokens, not raw hex values.
  • 05Adapt brand colors: Use lighter tints (300–400 range) of your brand color for interactive elements on dark backgrounds.
  • 06Respect system preferences: Always support prefers-color-scheme: dark as a fallback, even if you build a manual toggle.
  • 07Test contrast rigorously: Use a contrast checker on every text color pair, not just your primary body text.

Share this article

Build Your Dark Mode Palette with ColorPeek

Use ColorPeek's tools to generate, preview, and export a complete dark mode color system - from surface levels to semantic tokens.