How to create a three-state theme toggle with JavaScript: light, dark, and system preference
Most websites today offer a simple light/dark mode toggle, but users expect more. They want their apps to respect their system preferences while still giving them control. That’s where a three-state theme toggle comes in.
What makes three states better
Instead of just light and dark, you get:
- System mode: Follows whatever the user’s device is set to
- Light mode: Always light, no matter what
- Dark mode: Always dark, no matter what
This way, if someone prefers dark mode at night but light during the day, system mode handles it automatically. But if they always want dark mode, they can lock it in.
The component structure
Here’s the actual HTML structure that creates the three-button toggle:
<div
class="nline-flex items-center bg-base-100 dark:bg-base-800 rounded-full p-0.5 transition-colors duration-200 gap-1"
data-theme-toggle-container
>
<!-- System/Auto Button -->
<button
data-theme-option="system"
role="radio"
aria-checked="false"
aria-label="Use system theme"
title="System theme"
>
<!-- Icon -->
</button>
<!-- Light Button -->
<button
data-theme-option="light"
role="radio"
aria-checked="false"
aria-label="Light theme"
title="Light theme"
>
<!-- Icon -->
</button>
<!-- Dark Button -->
<button
data-theme-option="dark"
role="radio"
aria-checked="false"
aria-label="Dark theme"
title="Dark theme"
class="w-fit"
>
<!-- Icon -->
</button>
</div>
Key parts:
data-theme-option
attributes help JavaScript identify which button was clickedrole="radio"
andaria-checked
make it accessible as a radio button grouparia-label
andtitle
provide clear descriptions for screen readers- Icons make each option instantly recognizable
How it works
The tricky part is managing three different states. You need to track:
- What the user actually picked
- What their system prefers
- What theme is currently active
Saving user choices with localStorage
// Save what they picked
localStorage.setItem('theme-preference', 'dark');
// Get it back later
const savedTheme = localStorage.getItem('theme-preference') || 'system';
What’s happening here:
localStorage.setItem()
stores data in the browser that persists even after closing the tab- The first parameter is the key name (
'theme-preference'
) - The second parameter is the value (
'dark'
,'light'
, or'system'
) localStorage.getItem()
retrieves the stored value- The
|| 'system'
part means “if nothing is stored, default to system mode”
Checking system preference with matchMedia
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
Breaking this down:
window.matchMedia()
lets you check CSS media queries from JavaScript'(prefers-color-scheme: dark)'
is the same media query you’d use in CSS.matches
returnstrue
if the media query matches,false
if it doesn’t- So
systemPrefersDark
will betrue
if the user’s device is set to dark mode
Complete theme detection function
function getCurrentTheme() {
const savedTheme = localStorage.getItem('theme-preference') || 'system';
if (savedTheme === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
return savedTheme;
}
Step by step:
- Get the user’s saved preference (or default to ‘system’)
- If they chose ‘system’, check what their device prefers
- Return ‘dark’ if their device prefers dark, ‘light’ if it prefers light
- If they chose ‘light’ or ‘dark’ specifically, just return that
The CSS side
Your CSS needs to handle all three cases:
:root {
--bg-color: #ffffff;
--text-color: #333333;
}
[data-theme="dark"] {
--bg-color: #1a1a1a;
--text-color: #ffffff;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) {
--bg-color: #1a1a1a;
--text-color: #ffffff;
}
}
The last part is key - it applies dark mode when no specific theme is set AND the system prefers dark.
Applying the theme to the page
function applyTheme(theme) {
const root = document.documentElement;
if (theme === 'dark') {
root.setAttribute('data-theme', 'dark');
} else if (theme === 'light') {
root.setAttribute('data-theme', 'light');
} else {
root.removeAttribute('data-theme');
}
}
What this does:
- Gets the root HTML element (
document.documentElement
) - For dark mode: adds
data-theme="dark"
to the HTML tag - For light mode: adds
data-theme="light"
to the HTML tag - For system mode: removes the
data-theme
attribute (lets CSS media queries handle it)
Handling button clicks
function handleThemeToggle(newTheme) {
// Save the user's choice
localStorage.setItem('theme-preference', newTheme);
// Apply the theme immediately
const actualTheme = getCurrentTheme();
applyTheme(actualTheme);
// Update button states
updateButtonStates(newTheme);
}
The flow:
- Save what the user clicked to localStorage
- Figure out what theme should actually be active
- Apply that theme to the page
- Update the button appearance
Listening for system changes
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', function(e) {
const savedTheme = localStorage.getItem('theme-preference') || 'system';
// Only update if user is in system mode
if (savedTheme === 'system') {
const newTheme = e.matches ? 'dark' : 'light';
applyTheme(newTheme);
}
});
What’s happening:
addEventListener('change', ...)
listens for when the system preference changese.matches
tells you if the media query now matches (true = dark mode, false = light mode)- But we only update if the user is in system mode - if they picked light or dark specifically, we respect that
Making it smooth
Nobody wants jarring theme switches. Add transitions:
* {
transition: background-color 0.3s ease, color 0.3s ease;
}
Keep it simple - just fade between colors.
The initialization script
Put this in your page head to avoid flash of wrong content:
(function() {
const savedTheme = localStorage.getItem('theme-preference') || 'system';
let actualTheme;
if (savedTheme === 'system') {
actualTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
} else {
actualTheme = savedTheme;
}
if (actualTheme === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
} else if (actualTheme === 'light') {
document.documentElement.setAttribute('data-theme', 'light');
}
})();
Why this works:
- It’s wrapped in an immediately invoked function expression (IIFE) so it runs right away
- It happens before the page renders, preventing the flash of wrong theme
- It’s the same logic as before, just condensed and running immediately
Things to watch out for
Flash of wrong content
Make sure your initialization script loads fast. Put it in the head, not at the bottom.
Accessibility
Use proper buttons and labels. Screen readers should know what each option does.
Button states
Make sure your buttons show which theme is currently active. Use CSS or JavaScript to highlight the current state.
Why bother?
Users notice when apps respect their preferences. It’s a small touch that makes a big difference in how professional your site feels. Plus, it’s not that much extra code once you set it up.
The complete picture
A three-state toggle gives users the control they want without making them think about it. System mode works for most people most of the time. Light and dark modes are there when they need them.
Start with the component structure above, add the JavaScript functions, and you’ll have something that works well for everyone.
/Michael Andreuzza
One price.
Lifetime access.
-
34 Premium Astro Templates
-
All Future Templates Included
-
Unlimited Projects · Lifetime License




