Dark mode has gone from novelty to expectation. Users assume your application supports it, and they notice when it does not. But implementing dark mode correctly is more nuanced than inverting colors.
This guide covers everything from color theory to implementation patterns that create a genuinely good dark mode experience.
Good dark mode is designed, not just inverted
Why Dark Mode Matters
| Benefit | Description |
|---|---|
| Eye strain reduction | Less bright light in low-light environments |
| Battery savings | OLED screens use less power with dark pixels |
| User preference | Many users simply prefer it |
| Accessibility | Some visual impairments benefit from reduced glare |
| Professional expectation | Modern apps are expected to support it |
The Color Theory
Dark mode is not about inverting light mode. Colors behave differently on dark backgrounds.
Key Principles
1. Reduce, Do not Invert Contrast
Pure white (#FFFFFF) on pure black (#000000) creates harsh contrast that causes eye strain:
/* Bad: maximum contrast */
.dark {
background: #000000;
color: #FFFFFF; /* 21:1 contrast - too harsh */
}
/* Good: reduced contrast */
.dark {
background: #121212;
color: #E0E0E0; /* ~15:1 contrast - easier on eyes */
}2. Use Elevated Surfaces, Not Shadows
In light mode, shadows create depth. In dark mode, use lighter surfaces:
/* Light mode: shadows for depth */
.card-light {
background: #FFFFFF;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
/* Dark mode: surface elevation */
.card-dark {
background: #1E1E1E; /* Lighter than base #121212 */
box-shadow: none; /* Shadows do not show on dark */
}3. Desaturate Colors
Saturated colors vibrate against dark backgrounds:
/* Light mode */
.button-primary-light {
background: #2563EB; /* Saturated blue */
}
/* Dark mode: desaturated */
.button-primary-dark {
background: #3B82F6; /* Lighter, less saturated */
}Color System for Dark Mode
Build a semantic color system:
:root {
/* Light mode (default) */
--color-bg-base: #FFFFFF;
--color-bg-elevated: #F9FAFB;
--color-bg-overlay: rgba(0, 0, 0, 0.5);
--color-text-primary: #111827;
--color-text-secondary: #6B7280;
--color-text-muted: #9CA3AF;
--color-border: #E5E7EB;
--color-border-strong: #D1D5DB;
--color-primary: #2563EB;
--color-primary-hover: #1D4ED8;
--color-success: #059669;
--color-warning: #D97706;
--color-error: #DC2626;
}
[data-theme="dark"] {
/* Dark mode overrides */
--color-bg-base: #121212;
--color-bg-elevated: #1E1E1E;
--color-bg-overlay: rgba(0, 0, 0, 0.7);
--color-text-primary: #E0E0E0;
--color-text-secondary: #A0A0A0;
--color-text-muted: #707070;
--color-border: #2E2E2E;
--color-border-strong: #404040;
--color-primary: #3B82F6;
--color-primary-hover: #60A5FA;
--color-success: #10B981;
--color-warning: #FBBF24;
--color-error: #F87171;
}Implementation Approaches
CSS Custom Properties (Recommended)
/* Define tokens */
:root {
--bg: #FFFFFF;
--text: #1A1A1A;
}
[data-theme="dark"] {
--bg: #121212;
--text: #E0E0E0;
}
/* Use tokens */
body {
background: var(--bg);
color: var(--text);
}System Preference Detection
/* Match system preference */
@media (prefers-color-scheme: dark) {
:root {
--bg: #121212;
--text: #E0E0E0;
}
}JavaScript Theme Toggle
// Theme provider
type Theme = 'light' | 'dark' | 'system'
function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>(() => {
// Check localStorage, fallback to 'system'
return (localStorage.getItem('theme') as Theme) || 'system'
})
useEffect(() => {
const root = document.documentElement
// Remove existing theme
root.removeAttribute('data-theme')
if (theme === 'system') {
// Let CSS @media handle it
return
}
root.setAttribute('data-theme', theme)
localStorage.setItem('theme', theme)
}, [theme])
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
)
}Combining System + User Preference
function useTheme() {
const [userPreference, setUserPreference] = useState<Theme>(() =>
(localStorage.getItem('theme') as Theme) || 'system'
)
const systemPreference = useMediaQuery('(prefers-color-scheme: dark)')
const resolvedTheme = useMemo(() => {
if (userPreference === 'system') {
return systemPreference ? 'dark' : 'light'
}
return userPreference
}, [userPreference, systemPreference])
useEffect(() => {
document.documentElement.setAttribute('data-theme', resolvedTheme)
}, [resolvedTheme])
return {
theme: userPreference,
resolvedTheme,
setTheme: (theme: Theme) => {
setUserPreference(theme)
localStorage.setItem('theme', theme)
}
}
}Theme Toggle Component
function ThemeToggle() {
const { theme, setTheme } = useTheme()
return (
<div role="radiogroup" aria-label="Theme selection">
<button
role="radio"
aria-checked={theme === 'light'}
onClick={() => setTheme('light')}
>
<SunIcon /> Light
</button>
<button
role="radio"
aria-checked={theme === 'dark'}
onClick={() => setTheme('dark')}
>
<MoonIcon /> Dark
</button>
<button
role="radio"
aria-checked={theme === 'system'}
onClick={() => setTheme('system')}
>
<ComputerIcon /> System
</button>
</div>
)
}Handling Images and Media
Image Considerations
Some images need dark mode variants:
<!-- Using picture element -->
<picture>
<source
srcset="logo-dark.svg"
media="(prefers-color-scheme: dark)"
/>
<img src="logo-light.svg" alt="Logo" />
</picture>
<!-- Or with CSS -->
<img
src="logo-light.svg"
class="logo"
alt="Logo"
/>
<style>
[data-theme="dark"] .logo {
filter: invert(1);
/* Or use content: url(logo-dark.svg) */
}
</style>Dimming Images
Reduce image brightness in dark mode:
[data-theme="dark"] img:not([data-no-dim]) {
filter: brightness(0.9);
}Background Images
.hero {
background-image: url('hero-light.jpg');
}
[data-theme="dark"] .hero {
background-image: url('hero-dark.jpg');
}Preventing Flash
The "flash of wrong theme" on page load is jarring. Prevent it:
Inline Script in Head
<head>
<script>
// Run before body renders
(function() {
const stored = localStorage.getItem('theme')
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches
if (stored === 'dark' || (stored === 'system' && systemDark) || (!stored && systemDark)) {
document.documentElement.setAttribute('data-theme', 'dark')
}
})()
</script>
</head>With Next.js
// _document.tsx
export default function Document() {
return (
<Html>
<Head />
<body>
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
const stored = localStorage.getItem('theme')
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches
if (stored === 'dark' || (!stored && systemDark)) {
document.documentElement.setAttribute('data-theme', 'dark')
}
})()
`
}}
/>
)
}Component-Level Considerations
Shadows in Dark Mode
.card {
/* Light mode: shadow for depth */
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
[data-theme="dark"] .card {
/* Dark mode: border or lighter background instead */
box-shadow: none;
border: 1px solid var(--color-border);
/* Or */
background: var(--color-bg-elevated);
}Form Inputs
input, select, textarea {
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
color: var(--color-text-primary);
}
/* Browser autofill styling */
input:-webkit-autofill {
-webkit-box-shadow: 0 0 0 1000px var(--color-bg-elevated) inset;
-webkit-text-fill-color: var(--color-text-primary);
}Syntax Highlighting
Code blocks need theme-aware syntax highlighting:
/* Using CSS variables in Prism/Highlight.js themes */
.token.comment {
color: var(--code-comment);
}
.token.keyword {
color: var(--code-keyword);
}
:root {
--code-comment: #6A737D;
--code-keyword: #D73A49;
}
[data-theme="dark"] {
--code-comment: #8B949E;
--code-keyword: #FF7B72;
}Accessibility Considerations
Contrast Ratios
Dark mode must still meet WCAG contrast requirements:
| Element | Required Ratio |
|---|---|
| Body text | 4.5:1 minimum |
| Large text (18pt+) | 3:1 minimum |
| UI components | 3:1 minimum |
Test your dark mode colors:
// Contrast ratio calculation
function getContrastRatio(color1: string, color2: string): number {
const l1 = getLuminance(color1)
const l2 = getLuminance(color2)
const lighter = Math.max(l1, l2)
const darker = Math.min(l1, l2)
return (lighter + 0.05) / (darker + 0.05)
}Focus Indicators
Ensure focus indicators are visible in both modes:
:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}Testing Dark Mode
Checklist
| Test | Check |
|---|---|
| All text readable | Sufficient contrast |
| Images appropriate | No jarring bright images |
| Forms usable | Inputs visible, states clear |
| Focus visible | Keyboard navigation works |
| No flash | Theme loads before render |
| System preference | Respects OS setting |
| Toggle works | User can override |
| Persistence | Choice remembered |
Automated Testing
// Test both themes
describe('Component', () => {
test('renders correctly in light mode', () => {
document.documentElement.removeAttribute('data-theme')
render(<Component />)
// assertions
})
test('renders correctly in dark mode', () => {
document.documentElement.setAttribute('data-theme', 'dark')
render(<Component />)
// assertions
})
})Common Mistakes
1. Pure black backgrounds Use #121212 or similar, not #000000. Pure black is harsh.
2. Inverted colors without adjustment Colors need adjustment for dark backgrounds, not just inversion.
3. Ignoring images Bright images are jarring. Consider dimming or providing variants.
4. Forgetting third-party embeds Embedded content (maps, videos, widgets) may not respect your theme.
5. Flash of wrong theme Blocking script in head prevents this.
6. Not providing user control Always let users override system preference.
Quick Implementation Checklist
- Define CSS custom properties for colors
- Create light and dark color schemes
- Add system preference detection (
prefers-color-scheme) - Add user preference toggle with localStorage
- Prevent flash with inline head script
- Adjust images and media
- Test contrast ratios
- Test with keyboard navigation
- Test preference persistence
Dark mode done well feels invisible — users get what they expect without noticing the implementation. That requires attention to detail beyond just swapping colors.
Comments