The prefers-color-scheme Media Query
The simplest approach: use the prefers-color-scheme media query to detect the user's OS preference and apply dark styles automatically:
/* Default: light mode */
body {
background: #ffffff;
color: #1e293b;
}
/* Automatically applied when OS is in dark mode */
@media (prefers-color-scheme: dark) {
body {
background: #050508;
color: #e2e8f0;
}
}
The Right Way: CSS Custom Properties
Scattering dark mode overrides throughout your CSS leads to maintenance nightmares. The correct approach: define all colors as CSS custom properties and swap them in one place:
:root {
/* Light mode (default) */
--bg: #ffffff;
--bg-surface: #f8fafc;
--text: #1e293b;
--text-muted: #64748b;
--border: #e2e8f0;
--primary: #7c5cfc;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #050508;
--bg-surface: #0f0f14;
--text: #e2e8f0;
--text-muted: #94a3b8;
--border: rgba(255,255,255,0.08);
--primary: #9d7ffe; /* slightly lighter for dark bg contrast */
}
}
/* All components reference tokens, never raw colors */
.card {
background: var(--bg-surface);
color: var(--text);
border: 1px solid var(--border);
}
With this pattern, you write dark mode support once per token, not once per component. Every component automatically adapts to both modes.
Class-Based Toggle (Manual Dark Mode)
For a user-controlled toggle, use a .dark class on the <html> element instead of relying solely on the media query:
/* CSS: both media query and class-based */
:root { --bg: #ffffff; --text: #1e293b; }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #050508;
--text: #e2e8f0;
}
}
[data-theme="dark"] {
--bg: #050508;
--text: #e2e8f0;
}
/* JavaScript: toggle */
const toggle = document.getElementById('theme-toggle');
toggle.addEventListener('click', () => {
const current = document.documentElement.dataset.theme;
const next = current === 'dark' ? 'light' : 'dark';
document.documentElement.dataset.theme = next;
localStorage.setItem('theme', next);
});
// On load: restore saved preference
const saved = localStorage.getItem('theme');
if (saved) document.documentElement.dataset.theme = saved;
Handling Images in Dark Mode
Images designed for light backgrounds can look harsh or wrong on dark backgrounds. A few strategies:
/* Reduce brightness of images in dark mode */
@media (prefers-color-scheme: dark) {
img:not([src*=".svg"]) {
filter: brightness(0.9) contrast(1.05);
}
}
/* Use the picture element for mode-specific images */
/* (logos, illustrations that need different versions) */
SVG Dark Mode
SVG favicons and inline SVGs can respond to color scheme too. Use currentColor for CSS-inheritable colors, or embed a media query directly in the SVG file:
/* Inline SVG with currentColor */
.icon svg path {
fill: currentColor; /* inherits from CSS color property */
}
/* SVG file with embedded media query */
/* In favicon.svg: */
Dark Mode Design Principles
Implementing dark mode technically is straightforward; designing it well takes care:
- Don't use pure black (#000000) — Dark mode backgrounds are typically very dark grays (#050508, #121212), not pure black. Pure black creates excessive contrast and can cause eye strain on OLED screens (visual noise around bright elements).
- Don't invert everything — Dark mode is not "white becomes black." Shadows, in particular, should be darker (or removed) rather than becoming light glows.
- Elevation through lightness — In dark mode, higher elevation is expressed by slightly lighter surfaces, not shadows (which are less visible on dark). Use slightly lighter background values for floating elements.
- Saturated colors need adjustment — Bright brand colors designed for light backgrounds often fail contrast on dark backgrounds. Lighten or desaturate your primary color for dark mode contexts.
- Test with real content — Dark mode often reveals contrast issues invisible in light mode. Test with actual text, photographs, and data visualizations, not just flat color swatches.
Common Dark Mode Mistakes
- Flash of incorrect theme (FOIT): If you load theme preference from localStorage via JavaScript, there's a moment between page render and script execution where the wrong theme shows. Fix by inlining a tiny script in the
<head>that sets the data attribute synchronously before render. - Using opacity instead of dedicated dark colors:
opacity: 0.6on dark-mode text makes it dim and harder to read. Define explicit muted color tokens instead. - Forgetting form element styling: Browsers apply their own dark mode styles to form inputs inconsistently. Always define explicit background, border, and text colors for inputs that work in both modes.
- Hardcoding hex colors in component CSS: Any component that uses a raw hex color instead of a CSS variable will never participate in your dark mode system, no matter how well you define your tokens.