Dark mode is now a standard expectation for web applications. Here's a practical implementation guide covering the CSS-only approach, the JavaScript toggle approach, and how to handle user preferences correctly.
The CSS-only approach (system preference only)
If you only need to respond to the user's OS-level dark mode setting, prefers-color-scheme does the job with no JavaScript:
:root {
--bg: white;
--text: #1a1a1a;
--card-bg: #f8f8f8;
--border: #e2e8f0;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #1a1a2e;
--text: #e8e8e8;
--card-bg: #16213e;
--border: #2d3748;
}
}
body {
background: var(--bg);
color: var(--text);
}
Pros: no JavaScript, instant, no flash of incorrect theme on load.
Cons: user can't override — if their OS is in light mode, they can't switch to dark on your site.
The full implementation: system default + user override
This approach:
- Reads the OS preference as the default
- Lets users toggle with a button
- Persists the user's choice in
localStorage - Prevents flash on page load
The CSS
/* Light theme (default) */
:root {
--bg: #ffffff;
--text: #1a202c;
--surface: #f7fafc;
--border: #e2e8f0;
--accent: #2f855a;
}
/* Dark theme — applied by the data-theme attribute */
[data-theme="dark"] {
--bg: #1a202c;
--text: #f7fafc;
--surface: #2d3748;
--border: #4a5568;
--accent: #68d391;
}
body {
background: var(--bg);
color: var(--text);
transition: background 0.2s, color 0.2s;
}
The JavaScript
// Apply theme before page renders to prevent flash
(function() {
const stored = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = stored || (prefersDark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
})();
// Toggle button
const toggle = document.getElementById('theme-toggle');
toggle.addEventListener('click', () => {
const current = document.documentElement.getAttribute('data-theme');
const next = current === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
toggle.textContent = next === 'dark' ? '☀️' : '🌙';
});
// Set initial button state
const current = document.documentElement.getAttribute('data-theme');
toggle.textContent = current === 'dark' ? '☀️' : '🌙';
Why the IIFE? The immediately invoked function at the top runs synchronously before any render, so the correct theme is applied before the first paint. Without it, users on dark mode see a white flash before the script runs.
The HTML
<head>
<!-- Inline the theme IIFE here, not in an external file -->
<script>
(function() {
const stored = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.setAttribute(
'data-theme',
stored || (prefersDark ? 'dark' : 'light')
);
})();
</script>
</head>
<body>
<button id="theme-toggle" aria-label="Toggle dark mode">🌙</button>
</body>
The script must be inline in <head> — loading it as an external file introduces a delay that causes the flash.
Handling system preference changes at runtime
If the user changes their OS setting while on your site, you can optionally respond:
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', (e) => {
// Only follow system if user hasn't manually overridden
if (!localStorage.getItem('theme')) {
document.documentElement.setAttribute(
'data-theme',
e.matches ? 'dark' : 'light'
);
}
});
Colour selection for dark mode
Dark mode is not just inverting colours. Common mistakes:
Don't use pure black — #000000 background with #ffffff text is harsh and tiring. Use a dark blue-grey like #1a202c or #121212 (Material Design's recommendation).
Elevation through lightness — in dark mode, raised surfaces (cards, modals) are slightly lighter than the base background, not darker. This mimics how physical surfaces look under ambient light.
Check contrast — dark mode colours need WCAG contrast verification just like light mode. An orange accent that reads well on white may not have sufficient contrast on dark backgrounds. Use the Color Contrast Checker to verify both themes.
Test your colours in both themes — use the Color Picker to identify hex values from your design mockups, then check contrast ratios for both light and dark variants.
Accessibility notes
- Provide an explicit toggle — don't force users to change their OS setting to switch modes
- Use
aria-labelon the toggle button - Ensure focus indicators are visible in both themes
- Don't use colour alone to convey information — icons, text labels, or patterns should complement colour cues
Common mistakes
Using class instead of data-theme — either works, but data-theme is more semantic and less likely to conflict with CSS libraries.
Forgetting images and SVGs — dark mode might require different asset versions. Use picture with source media="(prefers-color-scheme: dark)" or CSS filter: invert(1) for simple icon sets.
Animating the initial load — the transition on body looks smooth during toggle but causes a flash on initial load if the IIFE runs after a paint. Consider disabling transition on load with a class and re-enabling it after the page is interactive.
Dark mode done right is invisible to users who have it enabled and optional for everyone else. The core pattern — CSS variables for tokens, data-theme attribute to swap, IIFE to prevent flash, localStorage to persist — works reliably across all modern browsers and is straightforward to maintain.
Top comments (0)