Build a CSS Dark Mode Toggle Switch (Source Code)
Implement a smooth dark/light mode toggle using CSS custom properties and JavaScript. Save the user's preference with localStorage.
Table of Contents
Dark mode has become an expected feature on modern websites. In this tutorial, youโll implement a polished animated toggle switch that remembers the userโs preference across page loads using localStorage.
CSS Custom Properties (Variables)
The key to easy dark mode is using CSS variables on the :root that get overridden on [data-theme="dark"]:
:root {
--bg: #ffffff;
--surface: #f9fafb;
--text: #111827;
--text-muted:#6b7280;
--border: #e5e7eb;
--primary: #04AA6D;
--shadow: rgba(0, 0, 0, 0.08);
}
[data-theme="dark"] {
--bg: #0f172a;
--surface: #1e293b;
--text: #f1f5f9;
--text-muted:#94a3b8;
--border: rgba(255, 255, 255, 0.08);
--primary: #34d399;
--shadow: rgba(0, 0, 0, 0.4);
}
/* All elements use variables instead of hardcoded colors */
body {
background: var(--bg);
color: var(--text);
transition: background 0.3s ease, color 0.3s ease;
}
The Toggle Switch HTML
<label class="toggle-switch" for="themeToggle" aria-label="Toggle dark mode">
<input type="checkbox" id="themeToggle" />
<div class="toggle-track">
<span class="toggle-icon light">โ๏ธ</span>
<span class="toggle-icon dark">๐</span>
<div class="toggle-thumb"></div>
</div>
</label>
Toggle Switch CSS
.toggle-switch { cursor: pointer; display: inline-flex; align-items: center; }
.toggle-switch input { display: none; }
.toggle-track {
width: 64px;
height: 32px;
background: #e5e7eb;
border-radius: 32px;
position: relative;
transition: background .3s;
display: flex;
align-items: center;
padding: 4px;
}
.toggle-switch input:checked + .toggle-track {
background: #1e293b;
}
.toggle-thumb {
width: 24px;
height: 24px;
border-radius: 50%;
background: #fff;
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
position: absolute;
left: 4px;
transition: transform .3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.toggle-switch input:checked + .toggle-track .toggle-thumb {
transform: translateX(32px);
}
.toggle-icon {
position: absolute;
font-size: 14px;
transition: opacity .2s;
}
.toggle-icon.light { left: 6px; }
.toggle-icon.dark { right: 6px; }
/* Show correct icon based on state */
.toggle-icon.dark { opacity: 0; }
input:checked + .toggle-track .toggle-icon.light { opacity: 0; }
input:checked + .toggle-track .toggle-icon.dark { opacity: 1; }
JavaScript โ Toggle + Persist
const toggle = document.getElementById('themeToggle');
const root = document.documentElement;
// Apply saved preference on load
const saved = localStorage.getItem('theme') || 'light';
root.setAttribute('data-theme', saved);
toggle.checked = saved === 'dark';
// Listen for toggle change
toggle.addEventListener('change', () => {
const theme = toggle.checked ? 'dark' : 'light';
root.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
});
Respecting System Preference
// On first visit (no saved preference), use the OS setting
if (!localStorage.getItem('theme')) {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = prefersDark ? 'dark' : 'light';
root.setAttribute('data-theme', theme);
toggle.checked = prefersDark;
}
// Also react to live OS theme changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
if (!localStorage.getItem('theme')) {
root.setAttribute('data-theme', e.matches ? 'dark' : 'light');
toggle.checked = e.matches;
}
});
Best Practice: Always check
prefers-color-schemeas the default โ it respects the userโs OS setting before theyโve ever interacted with your site.