Part of our complete guide: CSS Snippets →
UI Component · HTML + CSS + JS
Dark Mode Toggle
A smooth theme switcher with localStorage persistence, system preference detection, and zero flash on page load. Respects the OS setting until the user makes an explicit choice.
Last updated: October 2026 · 7 min read
A dark mode toggle switches your site between a light and dark theme by flipping a data-theme attribute on the <html> element. CSS variables define both themes. JavaScript saves the choice to localStorage and restores it before the first paint — so there's no flash.
What you get: Complete theme system with CSS variables, a toggle button, localStorage persistence, system preference detection, no flash of wrong theme, and accessible ARIA states. Fully tested across reloads, tabs, and OS changes.
Why dark mode is expected, not optional
In 2019 dark mode was a nice-to-have. In 2026 it's a baseline expectation. Every major OS has it. Every major app has it. Chrome, Safari, and Firefox all expose a prefers-color-scheme media query so sites can follow the OS setting automatically. When a site doesn't offer dark mode, users who browse at night feel it — the white flash is physically uncomfortable.
The naive implementation is a separate stylesheet toggled by a button. This causes a flash of the wrong theme on every page load because the stylesheet has to download and parse before the theme applies. The modern implementation uses CSS variables — the theme is defined by a single attribute flip, which happens before paint. No flash, no extra requests, instant switch.
The pattern below is what you'd build at production scale. It respects the OS preference by default. It saves explicit choices to localStorage. It restores the correct theme before the first pixel is painted. And it exposes the toggle's state to screen readers via role="switch" and aria-checked so blind users can understand what it does.
🎬 Live Demo
Click the floating switch in the top-right — the entire page changes theme. Reload: your choice persists.
🎬 Try It
Toggle the switch. Every surface on this page — nav, cards, tables, footer — switches. Reload and it stays.
Complete code
<!-- 1. Inline script in <head> — BEFORE any CSS. Prevents flash. -->
<script>
(function() {
var saved = localStorage.getItem('theme');
var system = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', saved || system);
})();
</script>
<!-- 2. CSS variables for both themes -->
<style>
:root {
--bg: #f8fafc;
--surface: #ffffff;
--border: #e2e8f0;
--text: #0f172a;
--text-muted: #64748b;
--accent: #2563eb;
}
html[data-theme="dark"] {
--bg: #0f172a;
--surface: #1e293b;
--border: #334155;
--text: #e2e8f0;
--text-muted: #94a3b8;
--accent: #3b82f6;
}
body {
background: var(--bg);
color: var(--text);
transition: background 0.25s, color 0.25s;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
color: var(--text);
}
</style>
<!-- 3. The toggle button -->
<button id="themeToggle" class="toggle-switch" role="switch" aria-checked="false" aria-label="Dark mode">
<span class="toggle-knob">☀️</span>
</button>
<style>
.toggle-switch {
position: relative;
width: 64px; height: 34px;
border-radius: 999px;
background: #cbd5e1;
border: none;
cursor: pointer;
padding: 0;
transition: background 0.25s;
}
html[data-theme="dark"] .toggle-switch { background: #3b82f6; }
.toggle-knob {
position: absolute;
top: 3px; left: 3px;
width: 28px; height: 28px;
border-radius: 50%;
background: #fff;
display: flex; align-items: center; justify-content: center;
font-size: 0.85rem;
transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
}
html[data-theme="dark"] .toggle-knob {
transform: translateX(30px);
}
</style>
<!-- 4. The toggle logic -->
<script>
var html = document.documentElement;
var toggle = document.getElementById('themeToggle');
var knob = toggle.querySelector('.toggle-knob');
function syncToggle() {
var isDark = html.getAttribute('data-theme') === 'dark';
toggle.setAttribute('aria-checked', String(isDark));
knob.textContent = isDark ? '🌙' : '☀️';
}
toggle.addEventListener('click', function() {
var current = html.getAttribute('data-theme');
var next = current === 'dark' ? 'light' : 'dark';
html.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
syncToggle();
});
syncToggle();
// Follow OS changes if the user hasn't made an explicit choice
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) {
if (!localStorage.getItem('theme')) {
html.setAttribute('data-theme', e.matches ? 'dark' : 'light');
syncToggle();
}
});
</script>
How the dark mode toggle works
The critical piece is the tiny script in the <head>. It reads localStorage and sets data-theme on <html> before the browser renders anything. Without it, the light theme paints first, then flips to dark — a jarring flash. This script must be inline (not defer/async) and must run before any visible content.
All theme-dependent values — backgrounds, text colors, borders — are defined as CSS custom properties under :root and html[data-theme="dark"]. When the attribute flips, every element using var(--bg) updates instantly. No stylesheet reload, no re-flow, no network request. Just a variable resolution.
When the user clicks the toggle, we save their choice to localStorage.setItem('theme', 'dark'). On every page load, the inline script reads this first. If it exists, it wins. If not, we fall back to prefers-color-scheme. This means: explicit user choices override system preference, but if they never clicked, we still respect their OS.
window.matchMedia('(prefers-color-scheme: dark)') tells us what the OS is set to. We also listen for change events on it — so if a user switches their OS theme while your site is open, and they haven't saved an explicit choice, your site follows along. If they have saved a choice, we ignore the OS change (their explicit choice wins).
The toggle is role="switch" with aria-checked reflecting the current theme. Screen readers announce it as "Dark mode, switch, on/off" — clearer than a checkbox. The visible knob icon (🌙 / ☀️) reinforces the state for sighted users. Both cues together mean the state is never color-only.
This is the fix for "the toggle isn't showing any change." If your HTML uses hardcoded Tailwind classes like bg-slate-950 or text-slate-300, the CSS variables won't be picked up automatically — you must override those utility classes to read from the variables. In this page, the <style> block redefines .bg-slate-950, .text-slate-300, etc. to use var(--bg) and var(--text). Without this step, only elements that already use variables would change.
🔬 Edge cases
The problems that break dark mode in production.
The most common bug. The data-theme attribute flips correctly, but elements use hardcoded Tailwind classes (bg-slate-950) instead of CSS variables. Fix: override the utility classes to read from variables, or refactor the markup to use variables directly. In this page, both are done — the <style> block redefines every relevant utility class.
The page renders in light mode for a split-second, then flips to dark. Users who chose dark mode see a bright flash every page navigation. Fix: inline the theme-restoring script in the <head>, before any CSS or body content. It must run synchronously before paint.
White text on pure black causes halation — the text appears to glow and bleed. Reading it for more than a minute causes eye strain. Fix: use near-black backgrounds (#0f172a, #111827) and off-white text (#e2e8f0). This is what every major site does.
A logo with a white background looks like a bright square in dark mode. Fix: use SVG logos with currentColor, provide two image variants swapped via CSS, or apply a subtle filter: brightness(0.9) in dark mode. Don't apply filters to photos — it destroys contrast.
A user with OS dark mode visits your site and gets a blazing white page. They have to find the toggle manually. Fix: on first visit, use prefers-color-scheme to pick the theme. Only override it after the user explicitly clicks.
The user picks dark mode, navigates to another page, and it's light again. Fix: save to localStorage on toggle, read from localStorage on every page load. This is the entire reason the inline script exists.
If * { transition: all 0.3s; } is used, layout properties animate too. Text "slides" into place, cards resize weirdly, the page looks broken. Fix: transition only color, background-color, border-color, and box-shadow. Never all.
Dark mode text is often #64748b on #1e293b — fails 4.5:1 contrast. Fix: use #e2e8f0 for body text, #94a3b8 for muted. Always test both themes against WCAG AA. Dark themes are where contrast violations hide.
♿ Accessibility
A theme toggle is a stateful control. Screen readers need to know its state, not just its presence.
| Requirement | Implementation | Why |
|---|---|---|
| Role | role="switch" | Identifies on/off control |
| State | aria-checked="true/false" | Screen reader announces current state |
| Label | aria-label="Dark mode" | Describes what the switch controls |
| Keyboard | Native button + Tab + Enter | Works without a mouse |
| Contrast | 4.5:1 in both themes | Dark mode often fails this |
The critical detail: aria-checked must update every time the theme changes — including when it's restored from localStorage on load, or when the OS preference changes. If the attribute is stale, a screen reader user hears "off" while the page is dark. Call syncToggle() after every theme change.
⚡ Performance
| Aspect | Value | Notes |
|---|---|---|
| CSS size | ~1KB | Variables only, no extra file |
| JS size | ~20 lines | Vanilla, no library |
| Switch speed | Instant | Attribute flip — no re-parse |
| Network | 0 bytes | No stylesheet download |
The flash is the performance story. It's not milliseconds — it's perceived jank. A flash of wrong theme makes the site feel slow even when it's fast. The inline script that fixes it costs ~200 bytes and saves the entire experience. Never skip it.
⚠️ Common mistakes
- Only toggling CSS variables, but HTML uses hardcoded classes. The page doesn't visibly change. This is the #1 bug — override the utility classes.
- Loading a separate dark CSS file. Doubles downloads, causes a flash while it loads. Use CSS variables.
- No inline script in the head. Flash of wrong theme on every navigation.
- Pure black background. Halation, eye strain. Use
#0f172arange instead. transition: allon the body. Layout properties animate, the page "melts" during theme switch.- Forgetting
aria-checkedupdates. Screen reader announces stale state. - Not respecting
prefers-color-scheme. OS dark mode users get flashed by white on first visit. - Testing only one page. The theme must hold across all pages, modals, tooltips, and error states.
❓ Frequently asked questions
How do I add a dark mode toggle to my website?
Define CSS variables for both themes under a [data-theme='dark'] selector. Add a button that toggles a data-theme attribute on the html element. Save the choice to localStorage and restore it on page load before paint to avoid flashing.
How do I prevent the flash of wrong theme on page load?
Add a tiny inline script in the head (before any CSS or body content) that reads localStorage and sets the data-theme attribute on the html element immediately. This runs before the first paint, so the correct theme is applied before anything renders.
Should I respect the user's system dark mode preference?
Yes. Use the prefers-color-scheme media query to detect the OS preference. If the user hasn't made an explicit choice on your site, default to their system preference. Once they toggle, their explicit choice wins and is saved.
Why use CSS variables for themes instead of separate stylesheets?
CSS variables switch instantly without downloading another stylesheet, don't require re-rendering layout, and keep all theme values in one place. Separate stylesheets double the CSS download and cause a flash while the new file loads.
Should dark mode use pure black (#000)?
No. Pure black with pure white text causes eye strain and halation (text appears to glow). Use a near-black like #0f172a or #111827 and off-white text like #e2e8f0. This is what most modern sites (GitHub, Tailwind, Material) do.
How do I make dark mode work with images and logos?
Either provide two image variants and swap via CSS (display: none on the inactive one), or use SVG logos with currentColor, or apply a subtle CSS filter like brightness(0.9) in dark mode. For photos, a slight opacity or brightness reduction often helps.
Does dark mode help with SEO?
Indirectly. Dark mode improves user experience metrics like time-on-page and reduces bounce rate, which are ranking signals. Google also respects prefers-color-scheme for its own UI. It won't directly boost rankings but it improves the signals that matter.
How do I store the theme preference?
Use localStorage with a key like 'theme' and a value of 'light' or 'dark'. Cookies are unnecessary for this. Only fall back to system preference when localStorage is empty. Never store a 'system' value — resolve it to 'light' or 'dark' when saving.