Left = light theme, Right = dark theme. The media query switches styling automatically.
1. prefers-color-scheme Media Query
The @media (prefers-color-scheme: dark) query automatically detects the user's operating system theme without JavaScript. This is the minimum baseline for dark mode support.
This page reads your system preference using matchMedia.
/* Basic dark mode with media query */
body {
background: #ffffff;
color: #111827;
}
@media (prefers-color-scheme: dark) {
body {
background: #0f172a;
color: #e2e8f0;
}
}
/* Check in JavaScript */
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
console.log(prefersDark); // true or false
/* Listen for OS theme changes */
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', e => {
console.log(e.matches ? 'dark' : 'light');
});2. CSS Variables: The Right Approach
Hardcoding colors in a media query quickly becomes messy. The professional approach is to define all colors as CSS custom properties in :root, then override them for dark mode. This keeps your component styles theme-agnostic.
Light palette: white background, dark text, indigo accent.
Dark palette: deep navy background, soft text, brighter indigo accent.
Light → Dark
Each value is a CSS variable. Swap the values, not every rule.
/* 1. Define variables in :root (light = default) */
:root {
--color-bg: #ffffff;
--color-surface: #f3f4f6;
--color-text: #111827;
--color-muted: #6b7280;
--color-accent: #6366f1;
--color-border: #e5e7eb;
}
/* 2. Override for dark mode */
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #0f172a;
--color-surface: #1e293b;
--color-text: #e2e8f0;
--color-muted: #94a3b8;
--color-accent: #818cf8;
--color-border: #334155;
}
}
/* 3. Use variables everywhere (no theme logic needed in components) */
body { background: var(--color-bg); color: var(--color-text); }
.card { background: var(--color-surface); border: 1px solid var(--color-border); }
.button { background: var(--color-accent); }
.label { color: var(--color-muted); }:root.3. JavaScript Toggle with data-theme
A user-controlled toggle lets people override their OS setting. The cleanest pattern is to set a data-theme attribute on the <html> element and scope CSS variable overrides to it. The toggle below controls this entire page. Try it!
Click the toggle to switch modes. Notice how the preview card above animates smoothly.
↑ toggle switches this
<html data-theme = "light" >
JavaScript only changes one attribute on <html>. CSS does all the heavy lifting.
/* CSS: scope dark vars to [data-theme="dark"] */
:root {
--color-bg: #ffffff;
--color-text: #111827;
}
[data-theme="dark"] {
--color-bg: #0f172a;
--color-text: #e2e8f0;
}
body {
background: var(--color-bg);
color: var(--color-text);
transition: background 0.3s ease, color 0.3s ease;
}// JavaScript toggle
const toggleBtn = document.getElementById('theme-toggle');
const html = document.documentElement; // <html> element
toggleBtn.addEventListener('click', () => {
const current = html.getAttribute('data-theme');
html.setAttribute('data-theme', current === 'dark' ? 'light' : 'dark');
});4. Remembering the Preference with localStorage
Without persistence, theme choice resets on every page load. Use localStorage to save the user's choice and re-apply it instantly (before the page renders) to avoid a flash of the wrong theme.
localStorage.getItem('theme')prefers-color-schemedata-theme on <html>Step 1–3 happens in a tiny <script> in <head> before anything renders, preventing a flash.
The stored value persists across page reloads and browser sessions.
/* =========================================================
Complete dark mode implementation. Copy this entire block.
========================================================= */
/* --- CSS in <head> --- */
:root {
--color-bg: #ffffff;
--color-surface: #f3f4f6;
--color-text: #111827;
--color-accent: #6366f1;
--color-border: #e5e7eb;
}
[data-theme="dark"] {
--color-bg: #0f172a;
--color-surface: #1e293b;
--color-text: #e2e8f0;
--color-accent: #818cf8;
--color-border: #334155;
}
body {
background: var(--color-bg);
color: var(--color-text);
transition: background 0.3s ease, color 0.3s ease;
}/* --- Inline script in <head> (BEFORE any other content) ---
Runs before body renders → no flash of wrong theme */
(function () {
const saved = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = saved ?? (prefersDark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
})();// --- Toggle button script (can be in <body>) ---
const btn = document.getElementById('theme-toggle');
const html = document.documentElement;
btn.addEventListener('click', () => {
const next = html.getAttribute('data-theme') === 'dark'
? 'light'
: 'dark';
html.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
// Optional: update button label/icon
btn.textContent = next === 'dark' ? 'Light mode' : 'Dark mode';
});5. Best Practices & Tips
Getting dark mode right goes beyond flipping colors. Here are the most common pitfalls and how to avoid them.
transition: background 0.3s, color 0.3s;
}
Add transition on body and key elements so the switch feels polished instead of abrupt.
✅ --color-accent: #6366f1
❌ --gray-100: #f3f4f6
✅ --color-surface: #f3f4f6
Name variables by their role (accent, surface, muted) not their appearance, so dark mode overrides make semantic sense.
Normal vs filter: invert(1)
Icons and illustrations may need separate dark variants or a CSS filter: invert(1) to remain readable.
body { transition: none; }
}
Skip the smooth transition for users who prefer reduced motion. Accessibility matters.
<link rel="stylesheet".../>
<!-- theme script before body -->
<script>…</script>
</head>
Place the theme-init script inside <head>, after CSS, to prevent a flash of unstyled/wrong-theme content (FOUT).
A three-state toggle (Light / Dark / System) gives users the most control. Store "light", "dark", or null (auto).
/* Full recommended implementation: CSS */
:root {
--color-bg: #ffffff;
--color-surface: #f3f4f6;
--color-text: #111827;
--color-accent: #6366f1;
--color-border: #e5e7eb;
}
/* User-chosen dark theme */
[data-theme="dark"] {
--color-bg: #0f172a;
--color-surface: #1e293b;
--color-text: #e2e8f0;
--color-accent: #818cf8;
--color-border: #334155;
}
/* OS dark, no saved preference (data-theme="auto") */
[data-theme="auto"] {
@media (prefers-color-scheme: dark) {
--color-bg: #0f172a;
--color-surface: #1e293b;
--color-text: #e2e8f0;
--color-accent: #818cf8;
--color-border: #334155;
}
}
body {
background: var(--color-bg);
color: var(--color-text);
transition: background 0.3s ease, color 0.3s ease;
}
@media (prefers-reduced-motion: reduce) {
body { transition: none; }
}// Full recommended implementation: JavaScript
// --- In <head>, before body renders ---
(function () {
const saved = localStorage.getItem('theme') ?? 'auto';
document.documentElement.setAttribute('data-theme', saved);
})();
// --- Toggle button (3 states) ---
const themes = ['light', 'dark', 'auto'];
let idx = themes.indexOf(document.documentElement.getAttribute('data-theme'));
document.getElementById('theme-toggle').addEventListener('click', () => {
idx = (idx + 1) % themes.length;
const next = themes[idx];
document.documentElement.setAttribute('data-theme', next);
if (next === 'auto') {
localStorage.removeItem('theme'); // let OS decide
} else {
localStorage.setItem('theme', next);
}
});