Step-by-step
-
1
Define color tokens in :root
Replace every hardcoded color in your CSS with a custom property. Define all light-mode values on
:root. This is your single source of truth — no color should appear anywhere else as a literal hex or rgb value.css:root { --bg: #ffffff; --bg-subtle: #f5f5f5; --surface: #ffffff; --border: #e0e0e0; --text: #111111; --text-muted:#666666; --accent: #0070f3; --accent-hover: #0050c9; } -
2
Override tokens under [data-theme="dark"]
Add a second block that re-declares only the color tokens with their dark-mode values. Everything else in your CSS stays unchanged — components just pick up the new variable values automatically.
css[data-theme="dark"] { --bg: #0d0d0d; --bg-subtle: #1a1a1a; --surface: #1e1e1e; --border: #2e2e2e; --text: #f0f0f0; --text-muted:#a0a0a0; --accent: #3b9eff; --accent-hover: #60b0ff; } -
3
Apply tokens to all elements
Go through your stylesheet and replace hardcoded colors. Use
var()everywhere — backgrounds, text, borders, shadows, inputs, buttons. If you miss a color it will not respond to the theme switch.cssbody { background-color: var(--bg); color: var(--text); } .card { background: var(--surface); border: 1px solid var(--border); } a { color: var(--accent); } a:hover { color: var(--accent-hover); } -
4
Add the toggle button
Put a button in your nav or header. The icon will update via JavaScript. Keep it simple — one button, one job.
html<button id="theme-toggle" aria-label="Toggle dark mode"> <span id="theme-icon">🌙</span> </button> -
5
Write the toggle JavaScript
The script reads the current theme from the
data-themeattribute, flips it, saves it tolocalStorage, and updates the button icon. Place this in your main JS file or a<script>at the end of<body>.javascriptconst btn = document.getElementById('theme-toggle'); const icon = document.getElementById('theme-icon'); const root = document.documentElement; btn.addEventListener('click', () => { const isDark = root.dataset.theme === 'dark'; const next = isDark ? 'light' : 'dark'; root.dataset.theme = next; localStorage.setItem('theme', next); icon.textContent = next === 'dark' ? '☀️' : '🌙'; }); -
6
Read prefers-color-scheme as the default
For first-time visitors who have no saved preference, read the OS-level setting via
prefers-color-scheme: dark. This is the only respectful default — do not force light mode on someone running dark mode everywhere.javascript// Called once on load to set the initial icon function applyStoredTheme() { const saved = localStorage.getItem('theme'); const system = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; const theme = saved ?? system; document.documentElement.dataset.theme = theme; document.getElementById('theme-icon').textContent = theme === 'dark' ? '☀️' : '🌙'; } applyStoredTheme(); -
7
Prevent the FOUC with an inline head script
If the theme JS runs after the page renders, visitors on dark mode will briefly see the light theme flash in. The fix: a tiny inline
<script>in<head>— before any CSS link — that setsdata-themesynchronously. It runs before paint, so there is nothing to flash.This script must be inline, not a deferred or async external file.
html<!-- Place this as the FIRST element inside <head> --> <script> (function () { var saved = localStorage.getItem("theme"); var system = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; document.documentElement.dataset.theme = saved || system; })(); </script> -
8
Smooth the transition
Add a short transition on properties that change between themes so the switch does not feel jarring. Only transition the color-related properties — not layout properties like
widthortransform, which would slow every animation.css*, *::before, *::after { transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease; }
Tips & gotchas
- Never use `prefers-color-scheme` in a `@media` block for your main theme colors — it cannot be overridden by a user toggle. Use `data-theme` instead.
- Store the user's preference as "light" or "dark", not as a boolean — it is easier to extend to a third theme later.
- Test with JavaScript disabled: the inline head script still runs, so the theme is applied, but the toggle button will not work. That is acceptable.
- If you use CSS-in-JS or Tailwind, the same principle applies — define the token names once and swap the values via a class or attribute on `<html>`.
- Icons and images may need separate dark-mode versions. Use `picture` with a `media="(prefers-color-scheme: dark)"` source or swap `src` via JS alongside the theme change.
Wrapping up
A proper dark mode implementation is built on three pillars: CSS custom properties that make every color swappable, a data-theme attribute that does the actual switching, and an inline head script that eliminates the theme flash. Get these three right and the rest — toggle button, persistence, OS default — follows naturally. The entire implementation is under 50 lines of code.