UseToolSuite UseToolSuite

Dark Mode Color Palette: A Developer's Guide

A practical guide to designing dark mode color palettes: surface elevation through lightness, WCAG text contrast, HSL desaturation, and the native CSS color-scheme property.

Necmeddin Cunedioglu Necmeddin Cunedioglu 6 min read
Part of the CSS Color Systems: A Complete Guide for Developers series

Practice what you learn

Color Converter

Try it free →

Dark Mode Color Palette: A Developer’s Guide

Dark mode is no longer a hidden Easter egg in the settings menu — it’s a baseline expectation. Telemetry from major platforms suggests 50–75% of users default to dark mode when the OS offers it.

But a good dark theme takes more thought than slapping filter: invert(1) on the <body>. A poor one causes eye strain, breaks your brand’s visual identity, and looks cheap or inaccessible.

This guide covers the color theory, vision principles, and CSS you need to build an accessible, good-looking dark mode palette.

1. The Anatomy of a Dark Mode Color System

Before assigning hex codes to CSS variables, it helps to understand the layers of a dark interface. A solid dark theme rests on four pillars:

  1. Base backgrounds: the lowest layer of the UI canvas.
  2. Elevated surfaces: cards, modals, and dropdowns that sit “above” the background.
  3. Typography: text from high-contrast headings to low-opacity placeholders.
  4. Brand & accent colors: the desaturated touches that guide attention and signal state (success, error, warning).
graph TD
    A[Dark Mode Architecture] --> B(Base Background)
    A --> C(Elevated Surfaces)
    A --> D(Typography)
    A --> E(Accents & Semantics)
    
    B --> B1[App Background #121212]
    C --> C1[Cards #1E1E1E]
    C --> C2[Modals #232323]
    D --> D1[Primary Text 87% Opacity]
    D --> D2[Secondary Text 60% Opacity]
    E --> E1[Primary Brand Variant]
    E --> E2[Success / Error States]

2. Avoid Pure Black and Pure White

The most common mistake is going fully binary:

/* ❌ Pure black and white cause eye strain */
:root[data-theme="dark"] {
  --bg-color: #000000;
  --text-color: #ffffff;
}

Why is this a problem? Pure white text (#FFFFFF) on a pure black OLED background (#000000) gives a 21:1 contrast ratio. That sounds like a WCAG win, but extreme contrast in dark environments triggers halation — light from bright pixels bleeds into the surrounding dark ones in the viewer’s eye. For the roughly 30% of people with astigmatism, this blurs the text and makes it harder to read over time.

The fix: use dark grays (#121212) for backgrounds and muted off-whites (#E0E0E0) for text.

3. Surface Hierarchy: Elevation via Lightness

In light mode, we signal elevation with drop shadows (box-shadow: 0 4px 6px rgba(0,0,0,0.1)). In dark mode, shadows are hard to see against an already dark background.

Modern design systems (Material Design 3, Apple’s HIG) use lightness to signal depth instead. The closer a surface is to the user, the lighter it gets.

Material Design elevation

Material standardizes a base canvas of #121212 and layers semi-transparent white overlays for elevated surfaces:

/* ✅ Elevation through lightness */
:root[data-theme="dark"] {
  --surface-0: #121212;  /* Base background (0dp) */
  --surface-1: #1e1e1e;  /* Cards, raised surfaces (1dp) */
  --surface-2: #232323;  /* Menus, dialogs (3dp) */
  --surface-3: #252525;  /* Navigation bars (6dp) */
  --surface-4: #272727;  /* Modals (8dp) */
  --surface-5: #2c2c2c;  /* Highest dialogs (16dp) */
}

Each step is a 1–3% increase in lightness. That creates a subtle sense of depth without heavy borders or invisible shadows.

Tinted dark themes via HSL

A plain #121212 gray can feel sterile. For a richer look, tint your dark grays toward your brand color using HSL. Pulling the hue slightly toward a blue or violet adds brand identity to the shadows without hurting readability.

/* A tinted dark theme using HSL */
:root {
  /* Base brand hue: a deep blue/purple */
  --brand-hue: 235;
  
  --bg-primary: hsl(var(--brand-hue), 20%, 7%);    /* ≈ #0e1015 */
  --bg-secondary: hsl(var(--brand-hue), 20%, 11%);  /* ≈ #171921 */
  --bg-tertiary: hsl(var(--brand-hue), 20%, 15%);   /* ≈ #20232e */
  --border-subtle: hsl(var(--brand-hue), 20%, 20%); /* ≈ #2b2f3d */
}

Tip: generate tinted dark surfaces from any base hex code with our local Color Palette Generator.

4. Text Colors and Opacity

Just as we avoid pure black backgrounds, we avoid pure white text. Group text into a hierarchy of emphasis.

Opacity vs solid hex

You can handle text color two ways: solid hex codes (#E0E0E0), or white with varying opacity. Opacity (rgba(255, 255, 255, alpha)) is usually preferred because the text picks up the subtle tint of whatever surface it sits on, keeping colors in harmony.

:root[data-theme="dark"] {
  /* Opacity lets text blend with the surface tint */
  --text-high-emphasis: rgba(255, 255, 255, 0.87);    /* Headings, body content */
  --text-medium-emphasis: rgba(255, 255, 255, 0.60);  /* Body text, metadata, dates */
  --text-disabled: rgba(255, 255, 255, 0.38);         /* Placeholders, disabled states */
}

WCAG contrast rules for dark mode text:

  • High emphasis text should hit at least 7:1 (WCAG AAA).
  • Medium emphasis text should hit at least 4.5:1 (WCAG AA).
  • Large text (18pt, or 14pt bold) can go down to 3:1.

5. High Contrast Mode vs Dark Mode

A common accessibility mistake is conflating dark mode with high contrast mode. They aren’t the same.

  • Dark mode is a comfort and aesthetic preference — it saves battery on OLED screens and reduces eye strain in low light. It uses soft grays, muted colors, and lightness-based elevation.
  • High contrast mode is an accessibility requirement for users with visual impairments (cataracts, macular degeneration). It needs pure black (#000000) and pure white (#FFFFFF), with no gradients, shadows, or subtle gray borders.

Respect both preferences independently using the forced-colors media query:

/* Respecting High Contrast Mode */
@media (forced-colors: active) {
  .card {
    border: 2px solid CanvasText;
    background: Canvas;
    box-shadow: none; /* Disable dark mode shadows */
  }
}

6. Accent Colors: Desaturation

One lesson that surprises people moving to dark mode: your vibrant light-mode brand colors will fail in dark mode.

Bright, saturated colors (a vivid corporate blue, a deep red) vibrate against dark backgrounds and cause fatigue. Very dark colors (a navy button) disappear into a dark gray background.

The desaturation technique

Generate a “dark mode alternate” for each accent color. The formula: increase lightness, decrease saturation.

/* Light mode primary button */
--accent-light: hsl(239, 84%, 55%); /* Vibrant, dark enough for white text */

/* Dark mode primary button */
--accent-dark: hsl(239, 74%, 67%); /* Softer, lighter, visible on dark gray */

Here we dropped saturation by 10% and raised lightness by 12%. That creates a softer variant that stays legible without straining the eye.

Adjusting HSL values? Convert hex codes and tweak saturation/lightness with our local Color Converter.

7. CSS Implementation Details

A robust dark theme isn’t just CSS variables — it’s state management, avoiding the flash of the wrong theme (FOIT), and respecting the OS preference.

1. The color-scheme property

Tell the browser your app supports dark mode. This themes native scrollbars, inputs, and checkboxes to match.

/* Put this at the top of your global CSS */
:root {
    color-scheme: light dark;
}

2. CSS variable architecture

Define light-mode variables on :root, then override them with a data-theme attribute for manual toggling and a media query for the OS preference.

/* 1. Default (light mode) */
:root {
  --bg-primary: #ffffff;
  --text-primary: #111827;
  --accent: #2563eb;
}

/* 2. OS preference (dark mode) */
@media (prefers-color-scheme: dark) {
  :root {
    --bg-primary: #111827;
    --text-primary: #f9fafb;
    --accent: #60a5fa;
  }
}

/* 3. Manual override (force dark) */
:root[data-theme="dark"] {
  --bg-primary: #111827;
  --text-primary: #f9fafb;
  --accent: #60a5fa;
}

3. Preventing the flash of the wrong theme (FOIT)

If your framework JavaScript runs at the bottom of the <body> (or is deferred), users see a flash of white before the dark-mode script runs. Fix it with a small synchronous script high in the <head>:

<head>
  <!-- Other meta tags -->
  <script>
    // Run synchronously to prevent the flash
    (function() {
      try {
        var localTheme = localStorage.getItem('app-theme-preference');
        var sysTheme = window.matchMedia('(prefers-color-scheme: dark)').matches;
        var theme = localTheme ? localTheme : (sysTheme ? 'dark' : 'light');
        document.documentElement.setAttribute('data-theme', theme);
      } catch (e) {}
    })();
  </script>
</head>

4. SVG fills via currentColor

Don’t hardcode hex colors in SVG icons — they’ll stay black in dark mode and vanish into the background.

<!-- ✅ The SVG inherits its parent's text color -->
<svg fill="currentColor" viewBox="0 0 24 24">
    <path d="M..."/>
</svg>

Further Reading


This article is part of our CSS Color Systems Guide series. Ready to build your dark mode foundation? Start with our CSS Gradient Generator.

Necmeddin Cunedioglu
Necmeddin Cunedioglu Author
6 min read
-- views

Software developer and the creator of UseToolSuite. I write about the tools and techniques I use daily as a developer — practical guides based on real experience, not theory.