The Right Approach
The cleanest way to implement dark mode uses two things together: CSS custom properties (variables) for all your colours, and the prefers-color-scheme media query to detect the user's OS preference.
This means zero JavaScript is required for basic dark mode. The toggle button (if you want one) is the only part that needs JS.
Set Up CSS Variables
Define all colours as CSS variables on :root for light mode, then override them inside the dark mode media query:
:root {
--bg: #ffffff;
--surface: #f8fafc;
--text: #0f172a;
--muted: #64748b;
--border: #e2e8f0;
--accent: #6366f1;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #060b14;
--surface: #0b1220;
--text: #dde8f8;
--muted: #4a6080;
--border: #0f1e32;
}
}
The Media Query
Now use only your CSS variables throughout your stylesheet — never hardcode colour values:
body {
background: var(--bg); /* ✅ correct */
color: var(--text);
}
/* NEVER do this: */
body {
background: #ffffff; /* ❌ won't respond to dark mode */
}
JavaScript Toggle Button
To let users manually override their OS preference, add a toggle. Use a data-theme attribute on <html>:
/* Override via data-theme attribute */
[data-theme="dark"] {
--bg: #060b14;
--surface: #0b1220;
--text: #dde8f8;
--muted: #4a6080;
--border: #0f1e32;
}
[data-theme="light"] {
--bg: #ffffff;
--surface: #f8fafc;
--text: #0f172a;
--muted: #64748b;
--border: #e2e8f0;
}
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);
});
Saving the Preference
Apply the saved preference before the page renders to prevent a flash of the wrong theme:
/* In <head> — before any CSS loads */
<script>
const saved = localStorage.getItem('theme');
const preferred = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', saved || preferred);
</script>
<head> before your CSS link — not at the bottom of <body>. If it runs after render, users will see a white flash on dark-mode pages.