How to Build a Dark Mode Toggle With CSS and JavaScript
Build a dark mode toggle with CSS variables, localStorage persistence, and prefers-color-scheme support. Full code, accessibility tips, and framework comparison included.
Get more content like this on Telegram!
Daily AI tips, notes & resources — free
Every site needs dark mode now. Not because it's a trend (though it is), but because it genuinely reduces eye strain, improves readability in low-light conditions, and respects user preferences that are set at the OS level.
Building it well takes a bit more thought than just inverting colors. Let me show you the complete implementation — system preference detection, user preference persistence, flash prevention, and accessibility.
The Architecture
A good dark mode implementation has three parts:
- CSS variables for all colors — so one class change swaps the entire palette
prefers-color-schememedia query — respects the OS default- JavaScript + localStorage — remembers the user's explicit choice
The interaction between these: on load, check localStorage first. If there's a saved preference, use it. If not, fall back to the OS preference. The toggle button overrides both and saves to localStorage.
Step 1: CSS Variables Foundation
Start by defining your entire color palette as CSS custom properties:
/* styles.css */
/* Light mode (default) */
:root {
--color-bg: #ffffff;
--color-bg-secondary: #f8fafc;
--color-bg-elevated: #ffffff;
--color-text-primary: #0f172a;
--color-text-secondary: #475569;
--color-text-muted: #94a3b8;
--color-border: #e2e8f0;
--color-border-strong: #cbd5e1;
--color-accent: #3b82f6;
--color-accent-hover: #2563eb;
--color-shadow: rgba(0, 0, 0, 0.08);
--color-code-bg: #f1f5f9;
}
/* Dark mode — applied when html has data-theme="dark" */
[data-theme="dark"] {
--color-bg: #0f172a;
--color-bg-secondary: #1e293b;
--color-bg-elevated: #1e293b;
--color-text-primary: #f1f5f9;
--color-text-secondary: #94a3b8;
--color-text-muted: #475569;
--color-border: #1e293b;
--color-border-strong: #334155;
--color-accent: #60a5fa;
--color-accent-hover: #93c5fd;
--color-shadow: rgba(0, 0, 0, 0.3);
--color-code-bg: #1e293b;
}
/* OS-level dark preference (when no user override) */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--color-bg: #0f172a;
--color-bg-secondary: #1e293b;
--color-bg-elevated: #1e293b;
--color-text-primary: #f1f5f9;
--color-text-secondary: #94a3b8;
--color-text-muted: #475569;
--color-border: #1e293b;
--color-border-strong: #334155;
--color-accent: #60a5fa;
--color-accent-hover: #93c5fd;
--color-shadow: rgba(0, 0, 0, 0.3);
--color-code-bg: #1e293b;
}
}
The :root:not([data-theme="light"]) selector is the key. When no user override exists, the OS preference applies. When the user explicitly chooses light mode, we set data-theme="light" on the html element, which prevents the media query dark colors from applying.
Now use these variables everywhere in your CSS:
body {
background-color: var(--color-bg);
color: var(--color-text-primary);
transition: background-color 0.2s ease, color 0.2s ease;
}
.card {
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
box-shadow: 0 1px 3px var(--color-shadow);
}
.nav {
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border);
}
code {
background: var(--color-code-bg);
color: var(--color-accent);
}
The transition on body gives a smooth theme switch animation. Keep it short — 200ms is enough.
Step 2: The Toggle Button HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="styles.css">
<!-- Critical: this runs BEFORE render to prevent flash -->
<script>
(function() {
const saved = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = saved || (prefersDark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
})();
</script>
</head>
<body>
<nav class="nav">
<a href="/" class="nav-brand">AiTechWorlds</a>
<!-- Dark mode toggle button -->
<button
id="theme-toggle"
class="theme-toggle"
aria-label="Switch to dark mode"
title="Toggle theme"
>
<!-- Sun icon (shown in dark mode — click to go light) -->
<svg class="icon-sun" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="12" cy="12" r="5"/>
<line x1="12" y1="1" x2="12" y2="3"/>
<line x1="12" y1="21" x2="12" y2="23"/>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
<line x1="1" y1="12" x2="3" y2="12"/>
<line x1="21" y1="12" x2="23" y2="12"/>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>
<!-- Moon icon (shown in light mode — click to go dark) -->
<svg class="icon-moon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
</button>
</nav>
<main>
<h1>Hello, World</h1>
<p>This page supports dark mode.</p>
</main>
<script src="theme.js"></script>
</body>
</html>
Step 3: The Toggle CSS
/* Toggle button styles */
.theme-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 8px;
border: 1px solid var(--color-border);
background: var(--color-bg-secondary);
color: var(--color-text-secondary);
cursor: pointer;
transition: background 0.2s, color 0.2s, border-color 0.2s;
}
.theme-toggle:hover {
background: var(--color-border);
color: var(--color-text-primary);
}
.theme-toggle:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
/* Icon visibility — show moon in light mode, sun in dark mode */
.icon-sun { display: none; }
.icon-moon { display: block; }
[data-theme="dark"] .icon-sun { display: block; }
[data-theme="dark"] .icon-moon { display: none; }
/* For OS dark preference without explicit override */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .icon-sun { display: block; }
:root:not([data-theme="light"]) .icon-moon { display: none; }
}
Step 4: The JavaScript
// theme.js
const toggle = document.getElementById('theme-toggle');
const html = document.documentElement;
// Get current effective theme
function getCurrentTheme() {
return html.getAttribute('data-theme') || 'light';
}
// Apply theme and save preference
function setTheme(theme) {
html.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
// Update aria-label for accessibility
toggle.setAttribute(
'aria-label',
theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'
);
}
// Toggle between light and dark
function toggleTheme() {
const current = getCurrentTheme();
setTheme(current === 'dark' ? 'light' : 'dark');
}
// Initialize the aria-label to match current state
setTheme(getCurrentTheme());
// Listen for button clicks
toggle.addEventListener('click', toggleTheme);
// Listen for OS preference changes (user changes system setting while on page)
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
// Only update if user hasn't set an explicit preference
if (!localStorage.getItem('theme')) {
html.setAttribute('data-theme', e.matches ? 'dark' : 'light');
}
});
That's the complete implementation. Let me explain the flash prevention technique.
Flash of Wrong Theme Prevention
The blocking script in <head> is critical:
<head>
<!-- This MUST be in <head>, NOT deferred, NOT async -->
<script>
(function() {
const saved = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = saved || (prefersDark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
})();
</script>
</head>
Why it works: the browser renders HTML top-to-bottom. Scripts in <head> without defer or async block rendering. So by the time the browser renders any visible content, we've already set data-theme on <html>. No flash.
If you put this script at the bottom of <body> or add defer, the page renders first in the default theme, then the script runs and switches — that's the white flash on dark mode preference.
Animated Toggle Switch (Optional Enhancement)
If you want a pill-style animated toggle instead of an icon button:
<label class="toggle-switch" for="theme-checkbox">
<input type="checkbox" id="theme-checkbox" class="toggle-input">
<span class="toggle-track">
<span class="toggle-thumb">
<svg class="toggle-icon" viewBox="0 0 24 24"><!-- sun/moon icon --></svg>
</span>
</span>
<span class="sr-only">Dark mode</span>
</label>
.toggle-switch { cursor: pointer; }
.toggle-input { position: absolute; opacity: 0; }
.toggle-track {
display: flex;
width: 52px;
height: 28px;
border-radius: 14px;
background: var(--color-border-strong);
padding: 3px;
transition: background 0.3s;
}
.toggle-input:checked + .toggle-track {
background: var(--color-accent);
}
.toggle-thumb {
width: 22px;
height: 22px;
border-radius: 50%;
background: white;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
.toggle-input:checked + .toggle-track .toggle-thumb {
transform: translateX(24px);
}
.toggle-input:focus-visible + .toggle-track {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
Implementation Comparison Table
| Approach | Complexity | Flash Risk | OS Sync | Persistence | Best For |
|---|---|---|---|---|---|
CSS prefers-color-scheme only | Very low | None | Yes | None | Static sites, no toggle |
| JS class toggle, no storage | Low | No | No | No | Demos, prototypes |
| JS + localStorage | Medium | Flash if done wrong | Optional | Yes | Most websites |
| JS + localStorage + flash prevention | Medium | None | Yes | Yes | Production |
| Server-side (cookie-based) | High | None | Yes | Cross-device | Authenticated apps |
Accessibility Notes
Dark mode itself is an accessibility feature, but the toggle needs to be accessible too:
<!-- The aria-label should describe the ACTION, not the current state -->
<button aria-label="Switch to dark mode" id="theme-toggle">...</button>
<!-- Update it when theme changes -->
<script>
toggle.setAttribute('aria-label',
isDark ? 'Switch to light mode' : 'Switch to dark mode'
);
</script>
Other considerations:
- Don't rely on color alone to convey information — dark mode just changes colors, any color-only cues are still problematic
- Ensure sufficient contrast in both themes (WCAG AA requires 4.5:1 for normal text)
- Test with a screen reader to confirm the toggle announces state changes
MDN's ARIA documentation has thorough coverage of button state announcements.
React and Framework Implementations
In React, the same pattern works but with hooks:
function useTheme() {
const [theme, setThemeState] = useState(() => {
if (typeof window === 'undefined') return 'light';
return localStorage.getItem('theme') ||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
});
const setTheme = useCallback((newTheme) => {
setThemeState(newTheme);
localStorage.setItem('theme', newTheme);
document.documentElement.setAttribute('data-theme', newTheme);
}, []);
const toggle = useCallback(() => {
setTheme(theme === 'dark' ? 'light' : 'dark');
}, [theme, setTheme]);
return { theme, setTheme, toggle };
}
For Next.js, use the next-themes library — it handles flash prevention correctly in SSR contexts, which is tricky to do manually with App Router.
The Next.js App Router notes cover the SSR considerations in more detail. For Tailwind-based dark mode, check the Tailwind CSS cheatsheet — Tailwind has a darkMode: 'class' config that integrates with this exact pattern.
Conclusion
A well-implemented dark mode toggle comes down to three things: CSS variables as your color system, a blocking script to prevent flash, and localStorage to persist the choice. Everything else is enhancement.
The code in this guide is production-ready for most static sites and single-page apps. For server-rendered applications with user accounts, extend it to save the preference to your database and set it as a cookie — then your server can render the correct theme on the first request.
Start with the CSS variables — they're worth having regardless of dark mode, since they make theming and design token changes much easier. Add the JavaScript on top, test the flash prevention, check your color contrast, and you're done.
FAQs
How do I prevent the flash of wrong theme on page load? Add a blocking script in the head element (before any CSS loads) that reads localStorage and applies the theme class immediately. This runs synchronously before the page renders, preventing the flash. Avoid using defer or async on this specific script.
Should I save the user's dark mode preference to localStorage or a database? For client-side sites, localStorage is fine. For authenticated apps with user accounts, save to the database so the preference follows the user across devices. You can do both: apply from localStorage immediately (no flash), then sync with the server after load.
What's the best way to handle dark mode in CSS — class or data attribute? Both work. Using a class like .dark on html is common with Tailwind CSS. Using a data attribute like data-theme='dark' on html is slightly more explicit and avoids class name conflicts. I prefer data-theme for custom implementations and class-based for Tailwind.
Frequently Asked Questions
AiTechWorlds Team
✓ Verified WriterThe AiTechWorlds team is passionate about AI, technology, and education. We create high-quality, research-backed content to help you learn, grow, and succeed in the modern digital world.
Related Articles
How to Use CSS Container Queries (Complete Tutorial 2026)
CSS container queries let components respond to their container size, not the viewport. Complete tutorial with syntax, real examples, browser support, and polyfills.
CSS Grid vs Flexbox: When to Use Which (With Cheatsheet)
CSS Grid or Flexbox? This guide breaks down when to use each layout method, with code examples, a decision table, and combined patterns.
Modern JavaScript Features You Must Know in 2026 (ES2026 Guide)
ES2026 brings new JavaScript features worth knowing — from import attributes to pipe operators. Here's every confirmed proposal with code examples and browser support.
7 Common React Performance Mistakes (And How to Fix Them)
These 7 React performance mistakes silently slow down your app. Before-and-after code fixes, React DevTools tips, and a profiling guide to find the real culprits.