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
#E8E8F0Primary Text (~15:1)
Main body text - off-white with a subtle cool tint, contrast ~15:1 on #121212
#A0A0B0Secondary Text (~7:1)
Captions, metadata, labels - readable but clearly subordinate
#606070Disabled Text (~3:1)
Disabled states, placeholder text
#4A4A5AHint 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
#4F46E5Dark Mode - Button Primary (lighter, same energy)
#818CF8Dark Mode - Subtle Background / Container
#312E81Notice 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: darkas 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.