A dark mode toggle built with CSS variables is one of those features that looks trivial until you ship it. Then the bug reports arrive: the page flashes white for a split second on reload, the toggle forgets the choice after navigation, or the site ignores the user’s operating system setting entirely.
This tutorial fixes all of that with zero dependencies. No Tailwind, no React, no theme library. Just CSS custom properties, one small vanilla JavaScript file, and a tiny inline script that kills the flash of wrong theme before the browser paints. It works on any static site: plain HTML, Hugo, Jekyll, Astro output, WordPress themes, GitHub Pages.
What we are going to build
- A semantic color token system using CSS custom properties (two palettes, one set of variables).
- Automatic respect for the
prefers-color-schememedia query on first visit. - A toggle button that saves the choice in
localStorageand survives reloads and page navigation. - An anti-FOUC (flash of unstyled/wrong theme) inline script.
- A three-state option: Light / Dark / System, which is what power users actually expect in 2026.
- Accessibility details (
aria-pressed,color-scheme) and multi-tab sync.
The full copy-paste code is at the end of the article.

Step 1: The HTML skeleton
Start with a minimal document. Two things matter here: the inline script in the <head> (we will explain it in step 5, but it must come first) and the toggle button.
<!doctype html>
<html lang='en'>
<head>
<meta charset='utf-8'>
<meta name='viewport' content='width=device-width, initial-scale=1'>
<meta name='theme-color' content='#ffffff'>
<title>Dark mode toggle with CSS variables</title>
<!-- 1. Anti-flash script: runs before the first paint -->
<script>
(function () {
try {
var saved = localStorage.getItem('theme');
var theme = (saved === 'light' || saved === 'dark')
? saved
: (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
} catch (e) {}
})();
</script>
<link rel='stylesheet' href='style.css'>
</head>
<body>
<header class='site-header'>
<span class='logo'>dailygit</span>
<button id='theme-toggle' class='theme-toggle' type='button' aria-pressed='false'>
<span class='theme-toggle__icon' aria-hidden='true'></span>
<span class='theme-toggle__label'>Dark mode</span>
</button>
</header>
<main>
<h1>Hello theme</h1>
<p>This card and this text are driven entirely by CSS variables.</p>
<div class='card'>A surface using var(--color-surface)</div>
</main>
<script src='theme.js' defer></script>
</body>
</html>
We toggle a data-theme attribute on <html> rather than on <body>. The root element is available earlier, and it lets the theme cascade to everything, including the scrollbar and form controls.
Step 2: Define light and dark palettes with CSS variables
The mistake most tutorials make is naming variables after colors (--blue, --gray-100). When you flip to dark mode, --white becoming black is confusing. Name your variables after their role instead.
Use two layers:
- Primitives: the raw color values of each palette.
- Semantic tokens: what the interface actually consumes (
--color-bg,--color-text,--color-border).
:root {
/* 1. Primitives (never used directly in components) */
--white: #ffffff;
--gray-50: #f5f6f8;
--gray-200: #dcdfe5;
--gray-500: #6b7280;
--gray-800: #23262d;
--gray-900: #16181d;
--near-black: #0f1115;
--blue-500: #2563eb;
--blue-400: #60a5fa;
/* 2. Semantic tokens: light theme is the default */
color-scheme: light;
--color-bg: var(--white);
--color-surface: var(--gray-50);
--color-text: var(--gray-900);
--color-text-muted: var(--gray-500);
--color-border: var(--gray-200);
--color-accent: var(--blue-500);
--color-accent-text: var(--white);
--shadow-card: 0 1px 3px rgba(16, 24, 40, 0.10);
}
/* Dark overrides: only the semantic layer changes */
:root[data-theme='dark'] {
color-scheme: dark;
--color-bg: var(--near-black);
--color-surface: var(--gray-800);
--color-text: #e8eaed;
--color-text-muted: #9aa2b1;
--color-border: #2f333c;
--color-accent: var(--blue-400);
--color-accent-text: var(--near-black);
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.6);
}
Then every component reads tokens only:
body {
background: var(--color-bg);
color: var(--color-text);
font: 16px/1.6 system-ui, sans-serif;
}
.card {
background: var(--color-surface);
border: 1px solid var(--color-border);
box-shadow: var(--shadow-card);
border-radius: 12px;
padding: 1.25rem;
}
a { color: var(--color-accent); }
A token table you can reuse
| Token | Light | Dark | Used for |
|---|---|---|---|
--color-bg |
#ffffff | #0f1115 | Page background |
--color-surface |
#f5f6f8 | #23262d | Cards, headers, inputs |
--color-text |
#16181d | #e8eaed | Body copy, headings |
--color-text-muted |
#6b7280 | #9aa2b1 | Captions, metadata |
--color-border |
#dcdfe5 | #2f333c | Dividers, outlines |
--color-accent |
#2563eb | #60a5fa | Links, primary buttons |
Pro tip: pure black on pure white is harsh, and pure white text on pure black causes halation. Slightly desaturated near-black and off-white values read much better. Also lighten your accent in dark mode: saturated blues fail contrast checks on dark backgrounds.
Do not forget color-scheme
Notice color-scheme: light and color-scheme: dark in the rules above. This single line tells the browser to render native UI (scrollbars, checkboxes, date pickers, form field autofill, the default canvas color) in the matching mode. Without it, you get white scrollbars glued to a dark page.
Step 3: Respect the prefers-color-scheme media query
On a first visit there is nothing in localStorage, so the site should follow the operating system. Add a media query that applies the dark tokens unless the user has explicitly chosen light:
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) {
color-scheme: dark;
--color-bg: var(--near-black);
--color-surface: var(--gray-800);
--color-text: #e8eaed;
--color-text-muted: #9aa2b1;
--color-border: #2f333c;
--color-accent: var(--blue-400);
--color-accent-text: var(--near-black);
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.6);
}
}
The :not([data-theme='light']) part is the important bit. It means: honor the system preference, but never override an explicit user choice. Because our variables live in one semantic layer, the override block is short, and it also guarantees a sensible dark theme for visitors with JavaScript disabled.

Step 4: The toggle script in vanilla JavaScript
Here is theme.js. It handles the click, persistence, the button’s accessible state, and the theme-color meta tag so mobile browser chrome matches the theme.
const STORAGE_KEY = 'theme';
const root = document.documentElement;
const toggle = document.getElementById('theme-toggle');
const systemQuery = window.matchMedia('(prefers-color-scheme: dark)');
function readStored() {
try {
const value = localStorage.getItem(STORAGE_KEY);
return value === 'light' || value === 'dark' ? value : null;
} catch (e) {
return null; // private mode or blocked storage
}
}
function store(value) {
try {
localStorage.setItem(STORAGE_KEY, value);
} catch (e) {}
}
function currentTheme() {
return root.getAttribute('data-theme')
|| (systemQuery.matches ? 'dark' : 'light');
}
function syncMetaThemeColor() {
const meta = document.querySelector('meta[name="theme-color"]');
if (!meta) return;
const bg = getComputedStyle(root).getPropertyValue('--color-bg').trim();
if (bg) meta.setAttribute('content', bg);
}
function applyTheme(theme, options = {}) {
root.setAttribute('data-theme', theme);
if (toggle) {
const isDark = theme === 'dark';
toggle.setAttribute('aria-pressed', String(isDark));
toggle.setAttribute('aria-label', isDark ? 'Switch to light theme' : 'Switch to dark theme');
const label = toggle.querySelector('.theme-toggle__label');
if (label) label.textContent = isDark ? 'Light mode' : 'Dark mode';
}
syncMetaThemeColor();
if (options.persist) store(theme);
}
// Initial sync (the inline script already set the attribute, we only sync the UI)
applyTheme(currentTheme());
// Click handler
if (toggle) {
toggle.addEventListener('click', () => {
applyTheme(currentTheme() === 'dark' ? 'light' : 'dark', { persist: true });
});
}
// Follow the OS if the user never chose manually
systemQuery.addEventListener('change', (event) => {
if (!readStored()) applyTheme(event.matches ? 'dark' : 'light');
});
// Keep other open tabs in sync
window.addEventListener('storage', (event) => {
if (event.key === STORAGE_KEY && event.newValue) applyTheme(event.newValue);
});
That is the entire logic: roughly 50 lines, no build step, no framework.
Step 5: Fix the flash of wrong theme (FOUC)
This is the part almost every tutorial gets wrong. If you set the theme from a deferred script or from DOMContentLoaded, the browser has already painted a light page. Dark mode users then see a white flash on every single navigation.
The fix is the small blocking, inline script we placed in the <head> in step 1. Rules to follow:
- It must be inline, not an external file (an external request can be delayed).
- It must be synchronous: no
defer, noasync, notype='module'. - It should sit before your stylesheet link, or at least before any visible markup.
- Wrap it in
try/catch:localStoragethrows in some privacy modes and inside sandboxed iframes. - Keep it tiny so it does not become a render-blocking cost. Around 300 bytes is fine.
If you use a strict Content Security Policy, add a nonce or a hash for this script instead of dropping unsafe-inline on your whole site:
<script nonce='RANDOM_PER_REQUEST'>/* theme init */</script>
Bonus fix: no ugly transition on the first switch
If you put transition: background-color .3s on body, the theme change looks smooth but the initial load can animate awkwardly, and rapid clicks feel laggy. Scope transitions to the elements you want, and let users opt out:
body, .card, .site-header {
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
}
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; }
}
Step 6: Upgrade to a three-state control (Light / Dark / System)
A two-state toggle has one flaw: once a user clicks it, they can never go back to “follow my device”. A three-state control solves it. Store light, dark, or system, and resolve system at runtime. Anyone digging further should read light-dark() CSS function.
<fieldset class='theme-switcher'>
<legend class='sr-only'>Color theme</legend>
<label><input type='radio' name='theme' value='light'> Light</label>
<label><input type='radio' name='theme' value='dark'> Dark</label>
<label><input type='radio' name='theme' value='system' checked> System</label>
</fieldset>
const KEY = 'theme-preference'; // 'light' | 'dark' | 'system'
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const html = document.documentElement;
function resolve(pref) {
if (pref === 'light' || pref === 'dark') return pref;
return mq.matches ? 'dark' : 'light';
}
function setPreference(pref) {
try { localStorage.setItem(KEY, pref); } catch (e) {}
html.setAttribute('data-theme', resolve(pref));
html.setAttribute('data-theme-pref', pref);
}
document.querySelectorAll('input[name="theme"]').forEach((input) => {
input.addEventListener('change', () => setPreference(input.value));
});
mq.addEventListener('change', () => {
let pref = 'system';
try { pref = localStorage.getItem(KEY) || 'system'; } catch (e) {}
if (pref === 'system') html.setAttribute('data-theme', resolve('system'));
});
Update the inline head script accordingly so it reads theme-preference and writes the resolved value into data-theme.

Step 7: The details that separate a good dark mode from a bad one
- Images and illustrations: use
<picture>withmedia='(prefers-color-scheme: dark)'for logos, or a variable-driven wrapper. Photos can be softened withfilter: brightness(.9)in dark mode. - Contrast: verify each token pair against WCAG. Target 4.5:1 for body text. Dark mode failures are usually muted text and accent colors.
- Elevation: shadows barely show on dark backgrounds. Signal elevation with lighter surfaces instead, which is why
--color-surfaceis a separate token. - Code blocks and embeds: iframes (YouTube, maps) do not inherit your variables. Give them a neutral border and a matching background wrapper.
- Accessibility: a toggle button should expose state with
aria-pressed. If you use a checkbox switch, userole='switch'andaria-checked. Never rely on icon color alone. - Print: add
@media print { :root { --color-bg: #fff; --color-text: #000; } }so no one prints a black page.
Bonus: the modern shortcut with light-dark()
CSS now offers light-dark(), which returns one of two values based on the used color scheme. Combined with color-scheme, it removes almost all duplication:
:root {
color-scheme: light dark; /* both supported */
--color-bg: light-dark(#ffffff, #0f1115);
--color-text: light-dark(#16181d, #e8eaed);
--color-border: light-dark(#dcdfe5, #2f333c);
}
:root[data-theme='light'] { color-scheme: light; }
:root[data-theme='dark'] { color-scheme: dark; }
Your JavaScript stays identical: it only flips data-theme, and color-scheme does the rest. This is supported across current versions of Chrome, Edge, Firefox and Safari, but if you must support older browsers, keep a fallback declaration before the light-dark() line, or stick with the two-block approach from steps 2 and 3.
Which approach should you pick?
| Approach | Pros | Cons |
|---|---|---|
data-theme attribute + CSS variables |
Widest support, easy to debug in DevTools, supports more than two themes | Token values declared twice |
.dark class on html |
Familiar to Tailwind users | Class list collisions, less expressive for 3+ themes |
light-dark() + color-scheme |
Shortest CSS, native form controls follow automatically | Limited to two schemes, needs a fallback for old browsers |
| Media query only, no toggle | Zero JavaScript | User cannot override the OS setting |
Common mistakes and how to fix them
| Symptom | Cause | Fix |
|---|---|---|
| White flash before dark theme appears | Theme applied after first paint | Inline synchronous script in <head> |
| Choice lost on reload | No persistence, or storage blocked | Write to localStorage inside try/catch |
| Bright white scrollbars and dropdowns | Missing color-scheme |
Declare color-scheme per theme |
| Toggle overrides the OS forever | Only two states stored | Add a system option (step 6) |
| Unreadable muted text in dark mode | Light-mode grays reused | Separate muted token per theme, check contrast |
| Some sections stay light | Hard-coded hex values in components | Search the CSS for # and rgb( and replace with tokens |

Full copy-paste implementation
Three files. Drop them into any static site.
index.html (head snippet)
<script>
(function () {
try {
var p = localStorage.getItem('theme');
var t = (p === 'light' || p === 'dark') ? p :
(matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', t);
} catch (e) {}
})();
</script>
style.css (theme layer)
:root {
color-scheme: light;
--color-bg: #ffffff;
--color-surface: #f5f6f8;
--color-text: #16181d;
--color-text-muted: #6b7280;
--color-border: #dcdfe5;
--color-accent: #2563eb;
--color-accent-text: #ffffff;
--shadow-card: 0 1px 3px rgba(16, 24, 40, 0.10);
}
:root[data-theme='dark'],
:root[data-theme='dark'] * { }
:root[data-theme='dark'] {
color-scheme: dark;
--color-bg: #0f1115;
--color-surface: #23262d;
--color-text: #e8eaed;
--color-text-muted: #9aa2b1;
--color-border: #2f333c;
--color-accent: #60a5fa;
--color-accent-text: #0f1115;
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.6);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) {
color-scheme: dark;
--color-bg: #0f1115;
--color-surface: #23262d;
--color-text: #e8eaed;
--color-text-muted: #9aa2b1;
--color-border: #2f333c;
--color-accent: #60a5fa;
--color-accent-text: #0f1115;
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.6);
}
}
body {
margin: 0;
background: var(--color-bg);
color: var(--color-text);
font: 16px/1.6 system-ui, sans-serif;
transition: background-color 0.2s ease, color 0.2s ease;
}
.theme-toggle {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.8rem;
border: 1px solid var(--color-border);
border-radius: 999px;
background: var(--color-surface);
color: var(--color-text);
cursor: pointer;
}
.theme-toggle:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
.theme-toggle__icon::before { content: '\2600'; }
:root[data-theme='dark'] .theme-toggle__icon::before { content: '\263D'; }
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; }
}
theme.js
Use the script from step 4 as is. Load it with defer: the visual theme is already correct thanks to the inline snippet, so this file only wires up interactivity.
Testing checklist before you ship
- Set your OS to dark, clear site data, hard reload. The page should be dark with no flash.
- Click the toggle to light, reload twice. It should stay light.
- Change the OS setting while the tab is open with no stored preference. The page should follow.
- Open two tabs, toggle in one. The other should update (storage event).
- Disable JavaScript. The media query fallback should still deliver a correct theme.
- Tab to the toggle with the keyboard and check the focus ring plus the announced state.
- Throttle the network to slow 3G and reload: still no flash, because the theme is set inline.
FAQ
How do I toggle dark mode using CSS?
CSS alone cannot store a user choice, but it can switch themes based on a state you put in the markup. Declare your colors as CSS variables on :root, override them under a selector such as :root[data-theme='dark'], and flip that attribute with one line of JavaScript. Pure CSS toggles using a hidden checkbox and the :has() selector exist, but they cannot remember the preference between page loads.
How do I set my CSS to dark mode by default?
Put your dark values directly on :root with color-scheme: dark, then override with :root[data-theme='light'] and a @media (prefers-color-scheme: light) block. Keep in mind that forcing dark by default ignores visitors whose device says light. A step-by-step version exists if you prefer that format.
How can I detect dark mode in CSS?
Use the media query @media (prefers-color-scheme: dark) { ... }. In JavaScript, use window.matchMedia('(prefers-color-scheme: dark)').matches and listen to its change event to react when the user changes the OS setting.
Should I store the theme in localStorage or a cookie?
For a static site, localStorage is simpler and the inline script reads it synchronously. Use a cookie if you render pages on a server and want to output the correct data-theme attribute in the HTML itself, which is the only way to be 100% flash-free with cached HTML and pre-rendering.
Why does my page still flash the wrong theme?
Almost always because the theme script is deferred, bundled, module-typed, or placed at the end of the body. It must be an inline, synchronous script inside the <head>. Also check that no other stylesheet hard-codes background: #fff on html or body.
Can I use this with more than two themes?
Yes, and that is the main advantage of the data-theme attribute over a boolean class. Add :root[data-theme='sepia'] or :root[data-theme='high-contrast'] blocks overriding the same semantic tokens, then cycle values in your JavaScript instead of toggling.
Does a dark mode toggle affect performance or SEO?
The inline script is a few hundred bytes and runs in under a millisecond, so Core Web Vitals are unaffected. Since the content stays identical in both themes, there is no cloaking risk. Just make sure text contrast is sufficient in both modes, because accessibility issues do surface in audits.
Wrapping up
A solid dark mode toggle with CSS variables comes down to four decisions: semantic tokens instead of raw colors, a data-theme attribute on the root element, prefers-color-scheme as the default, and an inline script that runs before the first paint. Add color-scheme for native controls and a system option for power users, and you have an implementation that beats most framework plugins with about 60 lines of code and zero dependencies.
