A practical, implementation-focused guide to building dark mode interfaces that look professional. Covers background stacks, text opacity levels, surface elevation, semantic colors, OLED considerations, and a complete CSS theme system.
Dark mode is not simply an inversion of your light mode palette. Reversing light and dark values produces harsh, unnatural-looking interfaces — bright text on black backgrounds can cause eye strain, saturated colors that look vibrant in light mode appear overwhelming in dark contexts, and the depth cues that shadows provide in light interfaces become invisible against dark backgrounds.
Building a proper dark mode requires rethinking the entire visual hierarchy from scratch: how backgrounds stack to create depth, how text should be expressed in terms of opacity rather than fixed grays, how brand colors need to shift in saturation, and how semantic signals (success, error, warning) need to be recalibrated for legibility in dark environments.
The good news is that once you understand the underlying principles, building a dark palette becomes methodical rather than guesswork. This guide walks through each decision layer by layer.
Surface Elevation Example
Cards use progressively lighter dark surfaces to create depth
Surface Level 1 — #1E1E1E
This is secondary text at 60% white opacity
Surface Level 2 — #252525
Elevated cards use a lighter dark tone
Disabled text at 38% white opacity
In light mode, depth is created through shadows — elements "float" above white surfaces by casting slight shadows. In dark mode, shadows become invisible against dark backgrounds. Instead, depth is created through surface lightness: elements at higher elevations use progressively lighter dark tones.
Google's Material Design formalized this approach with the concept of an "elevation-based overlay." The underlying principle is straightforward: the further a surface is from the base background, the lighter it appears, suggesting it is closer to the viewer. A 6-step elevation system covers the vast majority of interface needs.
| Level | Hex | Usage |
|---|---|---|
| Base Background | #121212 |
Page background — the darkest layer |
| Surface 1 | #1E1E1E |
Cards, panels, primary content areas |
| Surface 2 | #252525 |
Elevated cards, nested panels, dropdown backgrounds |
| Surface 3 | #2E2E2E |
Tooltips, popovers, modals |
| Surface 4 | #383838 |
Hover states, active states |
| Surface 5 | #444444 |
Top-level navigation bars, persistent headers |
#121212 reads as "dark enough to be dark" without the eye-strain of maximum contrast. Pure black is better reserved for OLED optimization, discussed later in this guide.One of the most important principles of dark mode typography is expressing text colors in opacity relative to white rather than as fixed gray hex values. This approach was popularized by Material Design and has become a best practice across design systems.
The reason: a fixed gray like #888888 looks different on each surface level. On the darkest background it appears relatively bright; on a lighter surface it appears barely distinguishable from the background. But rgba(255,255,255,0.60) — 60% white — adapts naturally to any background because it is always 60% brighter than whatever it sits on.
rgba(255,255,255,0.87): Headings, body text, important labels. Not pure white because 100% white creates the same harshness as pure black backgrounds — the slight reduction in opacity softens the reading experience over extended sessions.rgba(255,255,255,0.60): Descriptions, timestamps, supporting labels, metadata. Clearly readable but visually subordinate to primary text.rgba(255,255,255,0.38): Input field placeholders, disabled states, inactive menu items. Legible on close inspection but clearly not the focus of attention.rgba(255,255,255,0.12): Subtle separators between sections. Just enough to suggest structure without visually fragmenting the page./* Text opacity levels for dark mode */
--text-primary: rgba(255, 255, 255, 0.87);
--text-secondary: rgba(255, 255, 255, 0.60);
--text-disabled: rgba(255, 255, 255, 0.38);
--divider: rgba(255, 255, 255, 0.12);
#DEDEDE, 0.60 ≈ #999999, 0.38 ≈ #616161. However, the opacity-based approach is preferred wherever possible.Brand colors designed for light mode often fail in dark mode. The two most common issues are: (1) saturated accent colors become overwhelmingly vivid against dark backgrounds, and (2) colors that rely on contrast against white lose their visual weight when placed on dark surfaces.
Most saturated colors need to be pulled back in saturation and slightly shifted in lightness when used on dark surfaces. A vivid blue like #1E40AF looks authoritative on white but becomes garish and hard to look at against dark gray. On dark backgrounds, prefer a less saturated, slightly lighter variant.
A practical approach: for your primary brand color, create a dark-mode version by reducing saturation by 15–20% and increasing lightness by 10–15% in HSL. Then test against your darkest surface to verify the contrast ratio meets WCAG AA (minimum 4.5:1 for normal text).
#1E40AF → Dark mode #60A5FA (lighter, less saturated)#065F46 → Dark mode #34D399 (much lighter — dark greens disappear on dark surfaces)#DC2626 → Dark mode #F87171 (softer, higher lightness)#6D28D9 → Dark mode #A78BFA (lighter, slightly less saturated)#EA580C → Dark mode #FB923C (lighter, warm accent retained)Semantic colors — the greens, reds, yellows, and blues that signal status — need to be recalibrated for dark backgrounds. The light-mode versions of these colors are typically designed to contrast against white, not dark surfaces. On dark mode, they need higher lightness and sometimes adjusted saturation.
#34D399 (medium-bright emerald; high enough lightness to read clearly on #1E1E1E or darker)#F87171 (light coral-red; the standard #DC2626 is too dark for dark backgrounds)#FCD34D (bright enough to signal urgency without becoming yellow-white)#60A5FA (sky blue; readable, non-alarming, communicates informational tone)For semantic background fills (like the tinted background behind a success banner), use very dark, desaturated versions of the color: success background #064E3B, error background #7F1D1D, warning background #78350F, info background #1E3A5F. These provide contextual tinting without overwhelming the interface.
OLED and AMOLED screens (found in most modern flagship smartphones) do not backlight individual pixels — each pixel generates its own light. A black pixel on an OLED screen is literally off, consuming zero power. This means that pure #000000 backgrounds save measurable battery life on OLED devices.
However, pure black creates a higher-contrast experience that some users find harsh under normal lighting conditions. The pragmatic approach is to offer both: a standard dark mode using #121212 backgrounds (better default reading experience), and a "pure black" or "AMOLED" option using #000000 backgrounds (for users who prefer maximum battery savings or high contrast).
/* Standard dark mode */
@media (prefers-color-scheme: dark) {
:root {
--bg: #121212;
--surface: #1E1E1E;
--elevated: #252525;
}
}
/* OLED mode — opt-in via data attribute */
[data-theme="amoled"] {
--bg: #000000;
--surface: #0D0D0D;
--elevated: #1A1A1A;
}
The most maintainable approach to theming is a CSS custom properties system that defines all colors in one place and switches the entire theme by changing a single attribute. Here is a complete implementation covering all the layers discussed in this guide.
/* =====================
Light Mode (default)
===================== */
:root {
/* Backgrounds */
--bg: #FFFFFF;
--surface: #F8F9FA;
--elevated: #F1F3F5;
/* Borders */
--border: #DEE2E6;
--divider: #F0F0F0;
/* Text */
--text: #1A1A1A;
--text-muted: #6C757D;
--text-disabled:#ADB5BD;
--placeholder: #CED4DA;
/* Brand */
--primary: #2563EB;
--primary-hover:#1D4ED8;
--accent: #0EA5E9;
/* Semantic */
--success: #16A34A;
--success-bg: #DCFCE7;
--error: #DC2626;
--error-bg: #FEE2E2;
--warning: #D97706;
--warning-bg: #FEF3C7;
--info: #0284C7;
--info-bg: #E0F2FE;
}
/* =====================
Dark Mode
===================== */
@media (prefers-color-scheme: dark) {
:root {
/* Backgrounds */
--bg: #121212;
--surface: #1E1E1E;
--elevated: #252525;
/* Borders */
--border: rgba(255, 255, 255, 0.12);
--divider: rgba(255, 255, 255, 0.08);
/* Text */
--text: rgba(255, 255, 255, 0.87);
--text-muted: rgba(255, 255, 255, 0.60);
--text-disabled:rgba(255, 255, 255, 0.38);
--placeholder: rgba(255, 255, 255, 0.30);
/* Brand — lighter for dark surfaces */
--primary: #60A5FA;
--primary-hover:#93C5FD;
--accent: #38BDF8;
/* Semantic — recalibrated for dark */
--success: #34D399;
--success-bg: #064E3B;
--error: #F87171;
--error-bg: #7F1D1D;
--warning: #FCD34D;
--warning-bg: #78350F;
--info: #60A5FA;
--info-bg: #1E3A5F;
}
}
/* Optional: JavaScript-controlled theme */
[data-theme="light"] { /* same as :root above */ }
[data-theme="dark"] { /* same as @media dark above */ }
function toggleTheme() {
const root = document.documentElement;
const current = root.getAttribute('data-theme');
const next = current === 'dark' ? 'light' : 'dark';
root.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
}
// On page load: restore saved preference
const saved = localStorage.getItem('theme');
const system = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', saved || system);
Pure black is only appropriate on OLED devices or as an opt-in. On standard LCD screens, it creates uncomfortable contrast and makes text appear to float in a void. Use #121212 or #0F0F0F instead.
Inversion produces some predictable disasters: link colors that become neon, brand colors that look washed out, and text that appears completely wrong. Dark mode requires a purpose-built color set, not a mathematical transformation of light mode values.
On dark backgrounds, borders that used light-gray values in light mode (#E5E5E5, #F0F0F0) can disappear entirely or look jarringly bright. Replace all border colors with opacity-based values like rgba(255,255,255,0.12).
Vivid, fully-saturated colors as full-page or large-section backgrounds are taxing to look at in dark mode. Reserve high-saturation colors for small interactive elements — buttons, badges, and active states — and use them sparingly.
Dark mode colors look dramatically different across display technologies. OLED screens show pure blacks; some LCD panels have warm or cold tint profiles. A dark palette that looks perfect in Chrome DevTools may look very different on a physical device. Always test on real hardware before shipping.
User-uploaded images and third-party content will not automatically adapt to dark mode. Images with white backgrounds appear jarring against dark surfaces. Consider adding a very subtle border or slight tinting to image containers in dark mode: filter: brightness(0.9); applied to images in dark mode can reduce harsh white-background pop-outs.
Use this palette as a starting point for any dark mode project. All values have been tested for appropriate contrast ratios against their intended surface levels.
/* Complete ready-to-use dark mode variables */
:root[data-theme="dark"] {
--bg: #121212; /* Page background */
--surface-1: #1E1E1E; /* Cards, panels */
--surface-2: #252525; /* Elevated cards */
--surface-3: #2E2E2E; /* Modals, tooltips */
--surface-4: #383838; /* Hover states */
--text: rgba(255,255,255,0.87);
--text-2: rgba(255,255,255,0.60);
--text-3: rgba(255,255,255,0.38);
--border: rgba(255,255,255,0.12);
--primary: #60A5FA;
--primary-bg: #1E3A5F;
--success: #34D399;
--success-bg: #064E3B;
--error: #F87171;
--error-bg: #7F1D1D;
--warning: #FCD34D;
--warning-bg: #78350F;
--info: #60A5FA;
--info-bg: #1E3A5F;
}