Adding a dark mode toggle with CSS and JavaScript is one of the easiest ways to modernize a website in 2026. It improves user experience, respects system preferences, and reduces eye strain for visitors browsing at night. In this tutorial, we will build a lightweight, framework-free dark mode toggle that remembers user choice, respects the operating system theme, and works on any static site or WordPress installation.
By the end of this guide you will have a production-ready solution using CSS custom properties, the prefers-color-scheme media query, and localStorage for persistence.
Why Use CSS Variables for a Dark Mode Toggle?
Most older tutorials swap entire stylesheets or apply dozens of overrides. That approach is heavy, hard to maintain and slow. CSS custom properties solve this elegantly:
- Single source of truth: change one variable, update everything
- Zero repaint cost: browsers handle variable updates natively
- No framework required: works with plain HTML, WordPress themes, Astro, Eleventy, anything
- Tiny footprint: the full solution is under 40 lines of JavaScript

What We Are Building
Our dark mode toggle will:
- Detect the user’s operating system preference on first visit
- Allow manual switching via a button
- Save the choice in localStorage so it persists across pages and sessions
- Apply the theme before the page renders to avoid the dreaded flash of wrong theme (FOWT)
Step 1: Define the HTML Structure
Start with a minimal HTML skeleton. The toggle button can be placed in your header or navigation.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Dark Mode Toggle Demo</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<h1>My Website</h1>
<button id="theme-toggle" aria-label="Toggle dark mode">
<span class="icon-sun">☀</span>
<span class="icon-moon">☾</span>
</button>
</header>
<main>
<p>Content goes here.</p>
</main>
<script src="theme.js"></script>
</body>
</html>
Step 2: Set Up CSS Variables for Both Themes
Define your color palette as CSS custom properties on the :root element for the light theme, then override them with a [data-theme="dark"] attribute selector.
:root {
--bg-color: #ffffff;
--text-color: #1a1a1a;
--accent: #0066ff;
--card-bg: #f4f4f6;
--border: #e0e0e0;
}
[data-theme="dark"] {
--bg-color: #0f1115;
--text-color: #e8e8ea;
--accent: #4d9bff;
--card-bg: #1a1d24;
--border: #2a2e38;
}
body {
background: var(--bg-color);
color: var(--text-color);
transition: background 0.25s ease, color 0.25s ease;
font-family: system-ui, sans-serif;
}
#theme-toggle {
background: var(--card-bg);
border: 1px solid var(--border);
color: var(--text-color);
padding: 0.5rem 0.9rem;
border-radius: 999px;
cursor: pointer;
}
[data-theme="dark"] .icon-sun { display: none; }
[data-theme="light"] .icon-moon,
:root:not([data-theme="dark"]) .icon-moon { display: none; }
Respecting System Preference with prefers-color-scheme
For users who never click the toggle, we can still honor their OS setting using a media query as a fallback:
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) {
--bg-color: #0f1115;
--text-color: #e8e8ea;
--accent: #4d9bff;
--card-bg: #1a1d24;
--border: #2a2e38;
}
}

Step 3: The JavaScript Logic
Create theme.js. The script does three things: read the saved preference, apply it, and listen for click events on the toggle button.
(function () {
const STORAGE_KEY = 'theme-preference';
const root = document.documentElement;
function getPreferredTheme() {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) return stored;
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
function applyTheme(theme) {
root.setAttribute('data-theme', theme);
localStorage.setItem(STORAGE_KEY, theme);
}
// Apply immediately to prevent flash
applyTheme(getPreferredTheme());
document.addEventListener('DOMContentLoaded', () => {
const btn = document.getElementById('theme-toggle');
if (!btn) return;
btn.addEventListener('click', () => {
const current = root.getAttribute('data-theme');
applyTheme(current === 'dark' ? 'light' : 'dark');
});
});
// React to OS changes when no manual choice is set
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', (e) => {
if (!localStorage.getItem(STORAGE_KEY + '-manual')) {
applyTheme(e.matches ? 'dark' : 'light');
}
});
})();
Step 4: Preventing the Flash of Wrong Theme
This is the detail most tutorials skip. If your script loads at the bottom of the page, users will see a white flash before dark mode kicks in. The fix is to inline a tiny script in the <head>:
<script>
(function() {
var t = localStorage.getItem('theme-preference') ||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', t);
})();
</script>
Place this before your stylesheet link. It runs synchronously and sets the theme attribute before any paint occurs.
Comparing Approaches
| Approach | Pros | Cons |
|---|---|---|
| CSS class swap | Simple to grasp | Requires duplicated CSS rules |
| CSS variables + data-theme | Clean, scalable, tiny JS | Requires modern browser (all support it in 2026) |
| prefers-color-scheme only | Zero JavaScript | No user override possible |
| Framework libraries | Pre-built components | Heavy, overkill for a simple toggle |

Adding This to WordPress
If you run WordPress, you can integrate the dark mode toggle without a plugin:
- Add the CSS variables to your theme’s style.css or Additional CSS panel
- Enqueue
theme.jsusingwp_enqueue_scriptin yourfunctions.php - Add the inline flash-prevention script to
header.php, right after the opening<head>tag - Insert the toggle button in your header template or via a shortcode
Accessibility Best Practices
- Always include an
aria-labelon the toggle button describing its purpose - Ensure sufficient contrast in both themes (aim for WCAG AA, ratio 4.5:1 for body text)
- Consider adding
aria-pressedto reflect the toggle state - Do not disable transitions entirely, but keep them under 300ms to avoid disorientation
- Test with keyboard navigation using Tab and Enter
Common Mistakes to Avoid
- Loading the theme script at the bottom of the body: causes the flash of wrong theme
- Hardcoding colors in components: always use variables so both themes benefit automatically
- Forgetting images and SVGs: some assets may need filter adjustments or dark variants
- Ignoring form controls: native inputs need
color-scheme: light dark;in CSS for proper appearance
Frequently Asked Questions
How do I code dark mode in CSS?
Define your colors as CSS custom properties on :root, then override them under a selector such as [data-theme="dark"] or inside a @media (prefers-color-scheme: dark) block. Reference these variables throughout your stylesheet with var(--variable-name).
How do I toggle dark mode in HTML?
Add a <button> element and attach a JavaScript click handler that switches a data-theme attribute on the <html> element between light and dark. Your CSS variables will react automatically.
How do I remember the user’s dark mode choice?
Save the preference in localStorage when the user clicks the toggle, and read it back on page load. This makes the choice persist across sessions and pages.
Do I need a framework like React or Vue?
No. The entire solution shown here is vanilla JavaScript and works on any HTML page, WordPress site, or static site generator. Frameworks add convenience but no essential feature for this use case.
Does the prefers-color-scheme media query work in all browsers?
Yes. As of 2026, all major browsers (Chrome, Firefox, Safari, Edge, Opera, Brave, Arc) fully support prefers-color-scheme including on mobile.
Wrapping Up
Building a dark mode toggle with CSS variables and JavaScript takes less than 100 lines of code total and requires no dependencies. The combination of custom properties, prefers-color-scheme, and localStorage gives you a solution that is fast, accessible, and respectful of user preferences.
At Vibe Media we ship this pattern on every client website that requires theme switching. Feel free to copy the snippets above into your project and adapt the color values to match your brand. If you need help implementing this on a large-scale site or a custom WordPress theme, get in touch with our team.

0 Comments