Dark Mode With prefers-color-scheme and One Token File
Use one token file with light-dark(), add a saved toggle that does not flash, and check the colours in both themes.
Dark mode does not need a second stylesheet. One token file, a color-scheme declaration and a few lines of JavaScript cover the OS setting, a manual toggle and a saved choice.
Step 1: the token file
/* tokens.css */
:root {
--blue-600: #034bff;
--blue-300: #7aa2ff;
--gray-900: #080b0e;
--gray-600: #626976;
--gray-50: #f2f8ff;
--white: #ffffff;
color-scheme: light dark;
--surface: light-dark(var(--white), var(--gray-900));
--text: light-dark(var(--gray-900), var(--gray-50));
--text-muted: light-dark(var(--gray-600), #a9b1be);
--accent: light-dark(var(--blue-600), var(--blue-300));
}
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"] { color-scheme: dark; }
color-scheme: light dark tells the browser to follow the OS. light-dark() picks its first or second value from that. The two data-theme rules force one scheme when the visitor chooses.
Step 2: use the tokens, nothing else
body { background: var(--surface); color: var(--text); }
a { color: var(--accent); }
Step 3: the toggle, saved between visits
<script>
const saved = localStorage.getItem("theme");
if (saved === "light" || saved === "dark") document.documentElement.dataset.theme = saved;
</script>
<link rel="stylesheet" href="tokens.css">
const root = document.documentElement;
const button = document.getElementById("toggle");
const isDark = () =>
(root.dataset.theme ?? (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")) === "dark";
function sync() { button.setAttribute("aria-pressed", String(isDark())); }
button.addEventListener("click", () => {
const next = isDark() ? "light" : "dark";
root.dataset.theme = next;
localStorage.setItem("theme", next);
sync();
});
sync();
The first script goes in the <head>, before the stylesheet, so the saved theme is on the page before it is painted. Put it at the end and a dark-mode visitor sees a white flash on every load.
What it did in Chrome 154
first visit, OS light: rgb(255, 255, 255) aria-pressed: false
after click: rgb(8, 11, 14) theme: dark aria-pressed: true
after reload: rgb(8, 11, 14) saved theme: dark
first visit, OS dark: rgb(8, 11, 14) aria-pressed: true
Those are the body background colours. The click switched to dark and the choice survived a reload. A first visit with the OS set to dark started dark, with the button reporting that state.
⚠ Warning: localStorage can be blocked or full. Wrap the read and write in try/catch if your audience includes locked-down browsers, and let the OS setting be the fallback.
Check the colours
Test the contrast pairs for both themes. Muted text is 5.52:1 on white and 9.13:1 on the dark surface. The accent is 6.08:1 on white and 7.93:1 on dark. All of them pass 4.5:1 for normal text.
Common mistakes
- Putting the theme script after the stylesheet or at the bottom of the body, which flashes the wrong theme.
- Using the toggle button without
aria-pressed, so screen reader users cannot tell the state. - Defining dark colours by hand for each component instead of changing tokens.
Related course: HTML and CSS Advanced.
About
Use one token file with light-dark(), add a saved toggle that does not flash, and check the colours in both themes.
- Responsible
- Mary Q
- Last Update
- 08/20/2026
- Reading Time
- 3 min
Reviews
Sign in to write a reviewComments (0)
Sign in to commentNo comments yet.