Dark Mode Done Right: Design Systems for Modern Web Apps
Dark mode isn't a trend anymore. It's an expectation. Users want it, operating systems support it natively, and when done well, it can reduce eye strain, save battery on OLED screens, and simply look stunning.
But here's the problem: most dark mode implementations are afterthoughts. Developers flip background-color: black and call it a day, leaving users with harsh contrasts, illegible text, and an experience that feels broken rather than designed.
Let's build dark mode the right way: from color theory fundamentals to production-ready CSS architecture.
Why "Invert Colors" Doesn't Work
The most common mistake is treating dark mode as inverted light mode. It seems logical: white becomes black, black becomes white. But color relationships are more nuanced than simple inversion.
The Problems with Pure Inversion
1. Black backgrounds are too harsh
Pure black (#000000) against pure white (#FFFFFF) creates a contrast ratio of 21:1. While high contrast sounds good, it's actually fatiguing to read. The extreme difference causes "halation," where bright text appears to bleed into the dark background.
2. Color saturation looks wrong
Colors that work on white backgrounds often look garish against dark ones. A pleasant blue (#3B82F6) on white can feel electric and overwhelming on black.
3. Depth and elevation break down
In light mode, shadows create the illusion of elevation. In dark mode, shadows disappear into black backgrounds. You need different techniques to show depth.
4. Brand colors shift perception
Your carefully chosen brand green looks completely different in dark context. The same hex value reads differently against dark versus light backgrounds.
The Color Theory Behind Dark Mode
Understanding a few color theory principles will dramatically improve your dark mode implementation.
Luminance vs. Lightness
Luminance is the perceived brightness of a color by the human eye. It's not linear. We perceive differences in darker colors more easily than differences in lighter colors.
This means your dark mode color steps should be tighter at the dark end:
/* Light mode grays (even steps work fine) */
--gray-100: #f5f5f5; /* +10 */
--gray-200: #e5e5e5; /* +10 */
--gray-300: #d4d4d4; /* +10 */
/* Dark mode grays (tighter steps at dark end) */
--gray-800: #1f1f1f; /* Base */
--gray-850: #171717; /* +3 */
--gray-900: #0f0f0f; /* +4 */
--gray-950: #080808; /* +3 */
Color Temperature
Dark mode doesn't mean "black and white." Consider color temperature:
- Cool dark mode: Slight blue/purple tint (feels modern, tech-focused)
- Warm dark mode: Slight brown/orange tint (feels cozy, reduces eye strain)
- True neutral: Pure gray (feels clinical, can be harsh)
/* Cool dark mode */
--background: #0a0a12;
--surface: #12121a;
/* Warm dark mode */
--background: #0f0d0a;
--surface: #1a1814;
/* Neutral dark mode */
--background: #0a0a0a;
--surface: #141414;
Saturation Reduction
Colors need lower saturation in dark mode to avoid overwhelming the eye. A common rule: reduce saturation by 10-20% for dark mode.
/* Light mode: full saturation */
--primary-light: hsl(220, 90%, 56%);
/* Dark mode: reduced saturation */
--primary-dark: hsl(220, 75%, 60%);
Notice the lightness also increases slightly. Dark backgrounds swallow light, so colors need to be pushed brighter.
Building a Dark Mode Color System
Let's build a proper semantic color system that works for both modes.
Base Layer: Core Palette
Start with gray scales for each mode. These form your foundation:
:root {
/* Light mode grays */
--gray-50: #fafafa;
--gray-100: #f5f5f5;
--gray-200: #e5e5e5;
--gray-300: #d4d4d4;
--gray-400: #a3a3a3;
--gray-500: #737373;
--gray-600: #525252;
--gray-700: #404040;
--gray-800: #262626;
--gray-900: #171717;
--gray-950: #0a0a0a;
}
[data-theme="dark"] {
/* In dark mode, the scale conceptually inverts */
--gray-50: #0a0a0a;
--gray-100: #141414;
--gray-200: #1f1f1f;
--gray-300: #2e2e2e;
--gray-400: #404040;
--gray-500: #525252;
--gray-600: #737373;
--gray-700: #a3a3a3;
--gray-800: #d4d4d4;
--gray-900: #e5e5e5;
--gray-950: #fafafa;
}
Semantic Layer: Meaningful Names
Now map those primitives to semantic tokens that describe their purpose:
:root {
/* Background colors */
--color-background: var(--gray-50);
--color-surface: #ffffff;
--color-surface-elevated: #ffffff;
/* Text colors */
--color-text-primary: var(--gray-900);
--color-text-secondary: var(--gray-600);
--color-text-tertiary: var(--gray-400);
/* Border colors */
--color-border: var(--gray-200);
--color-border-hover: var(--gray-300);
/* Interactive colors */
--color-primary: hsl(220, 90%, 56%);
--color-primary-hover: hsl(220, 90%, 48%);
}
[data-theme="dark"] {
/* Background colors - note: not pure black */
--color-background: var(--gray-100); /* #141414, not #000 */
--color-surface: var(--gray-200); /* #1f1f1f */
--color-surface-elevated: var(--gray-300); /* #2e2e2e */
/* Text colors - note: not pure white */
--color-text-primary: var(--gray-900); /* #e5e5e5, not #fff */
--color-text-secondary: var(--gray-700);
--color-text-tertiary: var(--gray-500);
/* Border colors */
--color-border: var(--gray-300);
--color-border-hover: var(--gray-400);
/* Interactive colors - adjusted for dark */
--color-primary: hsl(220, 80%, 65%);
--color-primary-hover: hsl(220, 80%, 72%);
}
Component Layer: Specific Applications
For complex components, you might need component-specific tokens:
:root {
--card-background: var(--color-surface);
--card-border: var(--color-border);
--card-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
[data-theme="dark"] {
--card-background: var(--color-surface);
--card-border: var(--color-border);
/* Shadows need adjustment in dark mode */
--card-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
}
Elevation and Depth in Dark Mode
In light mode, you show elevation with shadows. In dark mode, you show elevation with lighter backgrounds.
The Material Design Approach
Google's Material Design popularized this concept: as surfaces rise, they get lighter in dark mode.
[data-theme="dark"] {
--elevation-0: #121212; /* Base layer */
--elevation-1: #1e1e1e; /* Cards, dialogs at rest */
--elevation-2: #232323; /* Raised cards */
--elevation-3: #272727; /* Navigation drawers */
--elevation-4: #2c2c2c; /* Modals, popovers */
}
Each level is slightly lighter, creating visual hierarchy without relying on shadows.
Combining with Subtle Shadows
You can still use shadows in dark mode. They just need to be more subtle and work with the elevation system:
[data-theme="dark"] {
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.5);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.5);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.6);
}
The higher opacity compensates for the darker environment.
Implementing the Theme Switch
Now let's implement the actual switching mechanism.
CSS Custom Properties + JavaScript
The most flexible approach uses CSS custom properties with a JavaScript toggle:
// ThemeProvider.tsx
'use client';
import { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'light' | 'dark' | 'system';
const ThemeContext = createContext<{
theme: Theme;
setTheme: (theme: Theme) => void;
}>({ theme: 'system', setTheme: () => {} });
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>('system');
useEffect(() => {
// Check for saved preference
const saved = localStorage.getItem('theme') as Theme;
if (saved) {
setTheme(saved);
}
}, []);
useEffect(() => {
const root = document.documentElement;
if (theme === 'system') {
// Follow system preference
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
root.setAttribute('data-theme', systemDark ? 'dark' : 'light');
} else {
root.setAttribute('data-theme', theme);
}
localStorage.setItem('theme', theme);
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export const useTheme = () => useContext(ThemeContext);
Respecting System Preference
Always support the prefers-color-scheme media query for users who haven't explicitly chosen:
/* Default to light */
:root {
--color-background: #ffffff;
--color-text: #171717;
}
/* System preference */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--color-background: #141414;
--color-text: #e5e5e5;
}
}
/* Explicit dark mode override */
[data-theme="dark"] {
--color-background: #141414;
--color-text: #e5e5e5;
}
Preventing Flash of Wrong Theme
Nothing ruins the dark mode experience like a blinding white flash on page load. Prevent it:
// In your root layout, add this script BEFORE any content
<head>
<script dangerouslySetInnerHTML={{
__html: `
(function() {
const theme = localStorage.getItem('theme');
if (theme === 'dark' ||
(theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) ||
(!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.setAttribute('data-theme', 'dark');
}
})();
`,
}} />
</head>
This runs synchronously before the page renders, preventing flash.
Accessibility Considerations
Dark mode introduces specific accessibility challenges.
Contrast Requirements Remain
WCAG contrast requirements don't change for dark mode:
- 4.5:1 for normal text
- 3:1 for large text and UI components
Test your dark mode palette:
Background: #141414
Text: #e5e5e5
Contrast ratio: 12.6:1 ✅
Background: #141414
Muted text: #525252
Contrast ratio: 3.3:1 ✅ (for large text only)
Focus Indicators
Focus indicators that work in light mode might disappear in dark mode:
/* Light mode focus */
:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Make sure it's visible in dark mode too */
[data-theme="dark"] :focus-visible {
outline: 2px solid var(--color-primary);
/* Primary color was adjusted for dark, so this works */
}
Image Handling
Some images look wrong in dark mode. Consider:
/* Reduce image brightness in dark mode */
[data-theme="dark"] img {
filter: brightness(0.9) contrast(1.05);
}
/* Invert diagrams/illustrations */
[data-theme="dark"] .invertible {
filter: invert(1) hue-rotate(180deg);
}
Testing Your Dark Mode
Build a testing checklist:
Visual Checklist
- [ ] No pure black backgrounds (use #121212 or similar)
- [ ] No pure white text (use #e5e5e5 or similar)
- [ ] Cards show elevation through lighter backgrounds
- [ ] Shadows are visible but subtle
- [ ] Brand colors look intentional, not washed out
- [ ] Focus states are clearly visible
- [ ] Form inputs have visible borders
Technical Checklist
- [ ] No flash of wrong theme on page load
- [ ] Theme persists across page navigation
- [ ] Theme persists across sessions (localStorage)
- [ ] System preference is respected when no preference is set
- [ ] Theme toggle is accessible via keyboard
- [ ] Images render appropriately
Accessibility Checklist
- [ ] All text meets contrast requirements
- [ ] Focus indicators are visible
- [ ] Interactive elements have visible boundaries
- [ ] Status colors (error, success) are distinguishable
Common Pitfalls to Avoid
1. Forgetting states Hover, focus, active, and disabled states all need dark mode variants. Build your design system to include all states.
2. Hard-coded colors
If you have color: #333 scattered through your CSS, dark mode will be a nightmare. Use semantic tokens from the start.
3. Third-party component conflicts Libraries like date pickers, modals, or rich text editors often have their own styles. Plan for overriding their dark mode implementations.
4. SVG icons
SVGs with hard-coded fill colors won't adapt. Use currentColor:
<svg fill="currentColor" viewBox="0 0 24 24">
<path d="..." />
</svg>
5. Assuming users want dark everywhere Some content (photos, marketing pages) might work better in light mode. Consider per-section preferences or at least test thoroughly.
Build Your Design System Right
Dark mode is one piece of a comprehensive design system. When built correctly, with semantic tokens, proper elevation, and accessibility baked in, it's maintainable and scalable.
At High Mountain Studio, we build design systems that handle themes, responsive design, and component variants systematically. If you're struggling with design system architecture or want to implement dark mode properly, reach out for a consultation.

