Dark mode is no longer a nice-to-have. Users expect it, browsers support it natively, and search engines reward sites that respect user preferences. In this tutorial, we walk through building a dark mode toggle with CSS and JavaScript that is fast, accessible, persistent across page loads, and free of any framework dependency.
You can drop this code into any existing site, whether it runs on WordPress, a static generator, or plain HTML.
What You Will Build
- A theme system powered by CSS custom properties (CSS variables).
- Automatic detection of the user’s OS theme via the prefers-color-scheme media query.
- A toggle button that lets users override that preference.
- localStorage persistence so the chosen theme survives reloads and navigation.
- A flash-free first paint (no white flicker when loading in dark mode).

Why CSS Variables Are the Right Tool
Before CSS custom properties, building a theme switcher meant duplicating stylesheets or toggling dozens of classes. Today, you define your colors once at the :root level and update them based on a single attribute. The browser handles the rest.
The advantages
- One source of truth for your color palette.
- Instant repainting when variables change.
- Works perfectly with native browser features.
- No JavaScript needed for the styling itself, only for the toggle logic.
Step 1: Define Your HTML Structure
Start with a minimal markup. We use a data-theme attribute on the html element because it gives us fine-grained control and avoids conflicts with utility classes.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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" aria-pressed="false">
<span class="icon-sun">☀</span>
<span class="icon-moon">☾</span>
</button>
</header>
<main>
<p>Content goes here.</p>
</main>
<script src="script.js"></script>
</body>
</html>
Step 2: Build the Theme with CSS Variables
Define light theme variables on :root, then override them when data-theme=”dark” is present. We also support users whose system is set to dark mode but who have not yet interacted with the toggle.
:root {
--color-bg: #ffffff;
--color-text: #1a1a1a;
--color-accent: #2563eb;
--color-border: #e5e7eb;
--transition: 0.25s ease;
}
[data-theme="dark"] {
--color-bg: #0f172a;
--color-text: #f1f5f9;
--color-accent: #60a5fa;
--color-border: #334155;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) {
--color-bg: #0f172a;
--color-text: #f1f5f9;
--color-accent: #60a5fa;
--color-border: #334155;
}
}
body {
background-color: var(--color-bg);
color: var(--color-text);
transition: background-color var(--transition), color var(--transition);
font-family: system-ui, sans-serif;
}
#theme-toggle {
background: transparent;
border: 1px solid var(--color-border);
color: var(--color-text);
padding: 0.5rem 0.75rem;
border-radius: 8px;
cursor: pointer;
}
.icon-moon { display: none; }
[data-theme="dark"] .icon-sun { display: none; }
[data-theme="dark"] .icon-moon { display: inline; }
The key trick: :root:not([data-theme]) means “only apply the OS dark mode if the user has not explicitly chosen a theme.” Once they click the toggle, their choice wins.

Step 3: Write the JavaScript Logic
The script needs to do three things: read any saved preference, apply it, and update it when the user clicks the button.
(function () {
const STORAGE_KEY = 'theme-preference';
const toggle = document.getElementById('theme-toggle');
const getStoredTheme = () => localStorage.getItem(STORAGE_KEY);
const getSystemTheme = () =>
window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
const applyTheme = (theme) => {
document.documentElement.setAttribute('data-theme', theme);
toggle.setAttribute('aria-pressed', theme === 'dark');
};
const currentTheme = getStoredTheme() || getSystemTheme();
applyTheme(currentTheme);
toggle.addEventListener('click', () => {
const next = document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
applyTheme(next);
localStorage.setItem(STORAGE_KEY, next);
});
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (!getStoredTheme()) {
applyTheme(e.matches ? 'dark' : 'light');
}
});
})();
Step 4: Eliminate the White Flash on Load
If your script tag sits at the bottom of the body, users in dark mode will briefly see a white background before the theme is applied. Fix this with a tiny inline script in the head:
<script>
(function () {
const stored = localStorage.getItem('theme-preference');
const system = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', stored || system);
})();
</script>
This runs before the body renders, so the correct theme is set on the first paint.
Decision Logic at a Glance
| localStorage value | OS preference | Applied theme |
|---|---|---|
| dark | any | dark |
| light | any | light |
| none | dark | dark |
| none | light | light |

Accessibility Best Practices
- Always include an aria-label on icon-only buttons.
- Use aria-pressed to communicate the current state to assistive technologies.
- Maintain a contrast ratio of at least 4.5:1 for body text in both themes.
- Avoid pure black (#000) backgrounds. A dark slate like #0f172a reduces eye strain.
- Respect the prefers-reduced-motion media query if you add transition animations.
Common Mistakes to Avoid
- Forgetting the inline head script leads to a visible flash for returning dark mode users.
- Hardcoding colors in individual components defeats the purpose of CSS variables.
- Storing booleans like “true” or “false” in localStorage. Use explicit strings (“dark”, “light”) instead, they are easier to debug.
- Ignoring images and SVGs. Add a slight filter or alternative assets for graphics that look harsh on dark backgrounds.
Going Further
Once the basics work, you can extend this pattern in several directions:
- Add a third option called “auto” that always follows the OS.
- Animate the toggle icon with a small SVG morph.
- Sync the theme across browser tabs using the storage event.
- Expose a CSS API so plugins or third-party widgets can adapt to your theme.
FAQ
Do I need a JavaScript framework to implement dark mode?
No. The approach shown here uses vanilla JavaScript and works in any environment. You can adapt it to React, Vue, or Svelte by moving the logic into a hook or composable, but the underlying mechanism stays the same.
Why use data-theme instead of a class?
Using a data attribute keeps your class names focused on layout and components. It also makes it trivial to add more themes later, such as a high-contrast or sepia mode, without rewriting the CSS structure.
Will this work in all modern browsers?
Yes. CSS custom properties, prefers-color-scheme, and localStorage are supported in every evergreen browser. As of 2026, you can use them confidently without polyfills.
How do I handle images that look bad in dark mode?
Two options. You can apply a subtle filter such as filter: brightness(.85) contrast(1.1) to images inside the dark theme, or you can use the picture element with a source that matches prefers-color-scheme to swap in a darker asset.
Should the toggle be in the header or the footer?
Place it where users expect it. The top right corner of the header is the most common pattern and the easiest to discover. Hiding it in the footer leads to lower engagement with the feature.
Does dark mode improve SEO?
Not directly, but it improves user experience signals such as time on page and reduced bounce rate, which indirectly support your rankings. It also signals to users that your site is modern and well maintained.