How to create a three-state theme toggle with JavaScript: light, dark, and system preference

Published and written on Jul 08 2025 by Michael Andreuzza

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.

Try it on this page!

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 clicked
  • role="radio" and aria-checked make it accessible as a radio button group
  • aria-label and title 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:

  1. What the user actually picked
  2. What their system prefers
  3. 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 returns true if the media query matches, false if it doesn’t
  • So systemPrefersDark will be true 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:

  1. Get the user’s saved preference (or default to ‘system’)
  2. If they chose ‘system’, check what their device prefers
  3. Return ‘dark’ if their device prefers dark, ‘light’ if it prefers light
  4. 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:

  1. Save what the user clicked to localStorage
  2. Figure out what theme should actually be active
  3. Apply that theme to the page
  4. 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 changes
  • e.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

Did you like this post? Please share it with your friends!

Astro v5
Tailwind CSS v4

One price.
Lifetime access.

  • 34 Premium Astro Templates

  • All Future Templates Included

  • Unlimited Projects · Lifetime License

Avatar 01
Avatar 02
Avatar 03
Avatar 04
Trusted by 9K+ customers.
Bundle for $159