How to Implement Dark Mode in CSS the Right Way
Build a robust dark mode using CSS custom properties, prefers-color-scheme, localStorage persistence and smooth transitions. No JavaScript frameworks required.
Dark mode is no longer a nice-to-have — users expect it, and their OS respects it. But there's a big difference between a dark mode that works and one that feels polished and intentional.
The browser exposes the user's OS preference via a media query:
:root {
--bg: #ffffff;
--text: #1a1a1a;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0d0d0d;
--text: #f0ede8;
}
}
body {
background: var(--bg);
color: var(--text);
}
This is the minimum viable dark mode — it respects the OS preference automatically, with zero JavaScript.
For user control, use a data-theme attribute on html and persist the choice in localStorage:
// Toggle function
function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
// Update icon
document.getElementById('themeIcon').className =
theme === 'dark' ? 'bi bi-sun-fill' : 'bi bi-moon-stars-fill';
}
// On load — respect saved preference, fall back to OS
const saved = localStorage.getItem('theme');
const preferred = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark' : 'light';
setTheme(saved || preferred);
Then in CSS, use [data-theme="dark"] instead of the media query — this lets JavaScript override the OS preference.
- Pure black backgrounds —
#000000is too stark. Use a very dark grey like#0d0d0dor#111111. It's easier on the eyes and makes layered surfaces possible. - Inverting images —
filter: invert(1)on photos looks terrible. Usefilter: invert(1) hue-rotate(180deg)for diagrams only. - Dark shadows on dark backgrounds — box shadows become invisible. Replace with a lighter border or a very subtle glow.
- Forgetting form elements — inputs, selects and checkboxes need explicit dark styles. Don't assume the browser will handle them.
/* Add to your global CSS — after initial load to prevent flash */
.theme-ready * {
transition:
background-color 0.2s ease,
color 0.2s ease,
border-color 0.2s ease;
}
// Add class after first paint to prevent transition flash on load
window.addEventListener('DOMContentLoaded', () => {
requestAnimationFrame(() => {
document.documentElement.classList.add('theme-ready');
});
});
Without the theme-ready guard, every page load will flash through the transition even before the user does anything.