Dark Mode Implementation
Implement proper dark mode with accessibility and performance in mind
Dark Mode Implementation
Dark mode reduces eye strain in low-light environments and saves battery on OLED screens. Implementing it properly requires careful attention to contrast, colors, and user experience.
Why Dark Mode Matters
Benefits:
- Reduces eye strain in low light
- Saves battery on OLED/AMOLED screens (up to 60%)
- Modern UI expectation
- Accessibility for light-sensitive users
Statistics:
- 82% of smartphone users use dark mode
- 70% prefer dark mode in apps that offer it
Color Strategy
Avoid Pure Black
/* ❌ BAD: Pure black (#000000) */
[data-theme="dark"] {
--bg: #000000; /* Too harsh, hard to see shadows */
--text: #FFFFFF; /* Too much contrast */
}
/* ✅ GOOD: Dark gray backgrounds */
[data-theme="dark"] {
--bg: #0A0A0A; /* Softer black */
--bg-elevated: #1A1A1A; /* Elevated surfaces */
--text: #E5E5E5; /* Softer white */
}Why? Pure black makes it hard to perceive depth (shadows don't show). Slightly lighter backgrounds create better hierarchy.
Elevation System
/* Light mode: elevation via shadows */
[data-theme="light"] {
--surface-0: #FFFFFF;
--surface-1: #FFFFFF;
--surface-2: #FFFFFF;
--surface-3: #FFFFFF;
--shadow-1: 0 1px 3px rgba(0,0,0,0.1);
--shadow-2: 0 4px 6px rgba(0,0,0,0.1);
--shadow-3: 0 10px 15px rgba(0,0,0,0.1);
}
/* Dark mode: elevation via lightness */
[data-theme="dark"] {
--surface-0: #0A0A0A; /* Base */
--surface-1: #1A1A1A; /* Raised 1dp */
--surface-2: #2A2A2A; /* Raised 2dp */
--surface-3: #3A3A3A; /* Raised 3dp */
--shadow-1: 0 1px 3px rgba(0,0,0,0.5);
--shadow-2: 0 4px 6px rgba(0,0,0,0.5);
--shadow-3: 0 10px 15px rgba(0,0,0,0.5);
}
/* Usage */
.card {
background: var(--surface-1);
box-shadow: var(--shadow-1);
}
.modal {
background: var(--surface-3);
box-shadow: var(--shadow-3);
}De-saturate Colors
// Light mode: Vibrant colors
const lightColors = {
primary: '#0066CC', // Bright blue
success: '#10B981', // Bright green
error: '#EF4444', // Bright red
};
// Dark mode: De-saturated colors
const darkColors = {
primary: '#4A9EFF', // Lighter, less saturated blue
success: '#34D399', // Lighter green
error: '#F87171', // Lighter red
};Why? Bright, saturated colors on dark backgrounds cause eye strain. Lighter, de-saturated colors are easier on the eyes.
Complete Implementation
1. Define Color Tokens
// tokens/colors.ts
export const colors = {
light: {
// Backgrounds
bg: {
default: '#FFFFFF',
subtle: '#F9FAFB',
muted: '#F3F4F6',
},
// Text
text: {
default: '#111827',
muted: '#6B7280',
subtle: '#9CA3AF',
},
// Borders
border: {
default: '#E5E7EB',
hover: '#D1D5DB',
},
// Brand
primary: '#0066CC',
secondary: '#6B7280',
// Feedback
success: '#10B981',
warning: '#F59E0B',
error: '#EF4444',
info: '#0066CC',
},
dark: {
// Backgrounds
bg: {
default: '#0A0A0A',
subtle: '#1A1A1A',
muted: '#2A2A2A',
},
// Text
text: {
default: '#E5E5E5',
muted: '#A3A3A3',
subtle: '#737373',
},
// Borders
border: {
default: '#404040',
hover: '#525252',
},
// Brand
primary: '#4A9EFF',
secondary: '#A3A3A3',
// Feedback
success: '#34D399',
warning: '#FBBF24',
error: '#F87171',
info: '#4A9EFF',
},
};2. Apply with CSS Variables
/* global.css */
:root {
--color-bg-default: #FFFFFF;
--color-text-default: #111827;
--color-primary: #0066CC;
/* ... */
}
[data-theme="dark"] {
--color-bg-default: #0A0A0A;
--color-text-default: #E5E5E5;
--color-primary: #4A9EFF;
/* ... */
}
/* Smooth transition */
* {
transition: background-color 0.2s ease, color 0.2s ease;
}3. Theme Provider
// components/DarkModeProvider.tsx
'use client';
import { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'light' | 'dark' | 'system';
interface ThemeContextValue {
theme: Theme;
setTheme: (theme: Theme) => void;
resolvedTheme: 'light' | 'dark';
}
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
export function DarkModeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<Theme>('system');
const [mounted, setMounted] = useState(false);
// Resolve system preference
const systemTheme = mounted && window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
const resolvedTheme = theme === 'system' ? systemTheme : theme;
// Apply theme
useEffect(() => {
if (!mounted) return;
document.documentElement.setAttribute('data-theme', resolvedTheme);
// Update meta theme-color
const metaTheme = document.querySelector('meta[name="theme-color"]');
if (metaTheme) {
metaTheme.setAttribute(
'content',
resolvedTheme === 'dark' ? '#0A0A0A' : '#FFFFFF'
);
}
}, [resolvedTheme, mounted]);
// Load saved preference
useEffect(() => {
const saved = localStorage.getItem('theme') as Theme;
if (saved) {
setThemeState(saved);
}
setMounted(true);
}, []);
// Listen to system changes
useEffect(() => {
if (!mounted) return;
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handler = () => {
if (theme === 'system') {
// Re-render with new system theme
setThemeState('system');
}
};
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}, [theme, mounted]);
const setTheme = (newTheme: Theme) => {
setThemeState(newTheme);
localStorage.setItem('theme', newTheme);
};
if (!mounted) {
return <>{children}</>;
}
return (
<ThemeContext.Provider value={{ theme, setTheme, resolvedTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useDarkMode() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useDarkMode must be used within DarkModeProvider');
}
return context;
}4. Theme Toggle
// components/DarkModeToggle.tsx
'use client';
import { useDarkMode } from './DarkModeProvider';
import { SunIcon, MoonIcon } from '@/components/icons';
export function DarkModeToggle() {
const { theme, setTheme, resolvedTheme } = useDarkMode();
const toggleTheme = () => {
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
};
return (
<button
onClick={toggleTheme}
aria-label={`Switch to ${resolvedTheme === 'dark' ? 'light' : 'dark'} mode`}
className="theme-toggle"
>
{resolvedTheme === 'dark' ? <SunIcon /> : <MoonIcon />}
</button>
);
}5. Prevent Flash
// app/layout.tsx
export default function RootLayout({ children }: Props) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<meta name="theme-color" content="#FFFFFF" />
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
const theme = localStorage.getItem('theme');
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
const resolvedTheme = theme === 'system' || !theme ? systemTheme : theme;
document.documentElement.setAttribute('data-theme', resolvedTheme);
const metaTheme = document.querySelector('meta[name="theme-color"]');
if (metaTheme) {
metaTheme.setAttribute('content', resolvedTheme === 'dark' ? '#0A0A0A' : '#FFFFFF');
}
})();
`,
}}
/>
</head>
<body>
<DarkModeProvider>
{children}
</DarkModeProvider>
</body>
</html>
);
}Image Handling
// Invert images in dark mode
.image-invert {
filter: brightness(0.8);
}
[data-theme="dark"] .image-invert {
filter: brightness(0.8) invert(1);
}
// Serve different images
function Logo() {
const { resolvedTheme } = useDarkMode();
return (
<img
src={resolvedTheme === 'dark' ? '/logo-dark.svg' : '/logo-light.svg'}
alt="Logo"
/>
);
}Accessibility
Maintain Contrast Ratios
Text on background:
- Normal text: 4.5:1 minimum (WCAG AA)
- Large text: 3:1 minimum (WCAG AA)
- Enhanced: 7:1 (WCAG AAA)
Test both themes!Tools
- Contrast Checker: WebAIM, Stark
- Browser DevTools: Chrome/Firefox contrast tools
Don't Rely on Color Alone
// ❌ BAD: Color only
<div className="text-error">Error occurred</div>
// ✅ GOOD: Color + icon + text
<div className="text-error">
<ErrorIcon />
<span>Error: Invalid input</span>
</div>Testing Dark Mode
// Test both themes
describe('Button', () => {
it('has sufficient contrast in light mode', () => {
render(
<DarkModeProvider theme="light">
<Button>Click me</Button>
</DarkModeProvider>
);
const button = screen.getByRole('button');
// Check contrast ratio >= 4.5:1
});
it('has sufficient contrast in dark mode', () => {
render(
<DarkModeProvider theme="dark">
<Button>Click me</Button>
</DarkModeProvider>
);
const button = screen.getByRole('button');
// Check contrast ratio >= 4.5:1
});
});Best Practices
- Test Both Themes: Every component in light and dark
- Maintain Contrast: WCAG AA minimum (4.5:1)
- Respect System: Default to
prefers-color-scheme - Smooth Transitions: Animate theme changes
- No Flash: Inline script before render
- Soft Black: Use #0A0A0A, not #000000
- De-saturate Colors: Lighter colors in dark mode
- Elevation: Lighter surfaces = higher elevation
- Test Images: Some may need inversion
- Meta Theme: Update mobile browser chrome
Common Pitfalls
❌ Pure black (#000): Too harsh
✅ Soft black (#0A0A0A)
❌ Same colors: Light theme colors in dark
✅ De-saturated, lighter colors
❌ No elevation: Flat surfaces
✅ Lighter = elevated
❌ Flash on load: Wrong theme briefly shown
✅ Inline script to prevent flash
❌ Ignored images: Look bad in dark mode
✅ Different images or filters
Dark mode is now a baseline expectation—implement it thoughtfully from the start!