Right, so you want a dark mode toggle. Not just a little switch that winks an eye and hopes for the best, but a proper, persistent, system-respecting one. We’ve all seen the janky versions—the ones that flashbang you at 2 AM or forget your preference the moment you reload. We’re not building that. We’re building the one that does it right, because frankly, the user experience here is embarrassingly easy to screw up. Let’s get it right on the first try.

The core of this entire operation is CSS Custom Properties, aka CSS variables. If you’re not using these for theming yet, you will be by the end of this section. They’re the workhorse, and they’re going to do all the heavy lifting. The JavaScript? That’s just the nervous system telling the muscles what to do. We’ll start by defining our two color schemes right in the root of the document. This isn’t just about flipping a background-color; it’s about creating a palette.

:root {
  /* Light Mode (Default) */
  --color-bg: #fff;
  --color-text: #222;
  --color-accent: #d62c2c;

  /* We'll set up the dark mode properties here too, but leave them empty for now.
     This acts as our fallback. If a variable isn't set in `[data-theme]`, it will
     use the one defined here in `:root`. */
  --color-bg-dark: ;
  --color-text-dark: ;
  --color-accent-dark: ;
}

[data-theme="dark"] {
  /* Dark Mode */
  --color-bg: var(--color-bg-dark, #111);
  --color-text: var(--color-text-dark, #eee);
  --color-accent: var(--color-accent-dark, #ff6b6b);
}

body {
  background-color: var(--color-bg);
  color: var(--color-text);
  font-family: sans-serif;
  transition: background-color 0.3s ease, color 0.3s ease;
}

a {
  color: var(--color-accent);
}

See what we did there? The [data-theme="dark"] selector overrides the variables defined in :root for any element within its scope (which, since it’s on <html>, is everything). The transition on the body gives it that smooth, non-janky fade between states. It’s a tiny detail that makes it feel polished.

The Toggle Mechanism

Now for the brains. We need a single source of truth for the current theme state. We’ll use localStorage to remember the user’s choice and a data-theme attribute on the <html> element to apply it. The beauty of this pattern is its simplicity. Here’s the script:

class ThemeToggler {
  constructor() {
    this.theme = null;
    this.init();
  }

  init() {
    // 1. Check localStorage first for a user's saved preference
    const savedTheme = localStorage.getItem('theme');
    // 2. If no saved preference, check the system preference
    const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;

    // Set the initial theme state
    if (savedTheme) {
      this.theme = savedTheme;
    } else {
      this.theme = systemPrefersDark ? 'dark' : 'light';
    }

    // Apply it immediately to avoid FOUC (Flash of Unstyled Content)
    this.applyTheme();

    // Listen for system preference changes, but only if the user hasn't set a preference
    // This is the polite thing to do. If they manually chose 'light', they probably don't
    // want the OS shoving 'dark' down their throat at sunset.
    this.mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
    this.mediaQuery.addEventListener('change', (e) => {
      if (!localStorage.getItem('theme')) {
        this.theme = e.matches ? 'dark' : 'light';
        this.applyTheme();
      }
    });
  }

  get isDark() {
    return this.theme === 'dark';
  }

  toggle() {
    this.theme = this.isDark ? 'light' : 'dark';
    localStorage.setItem('theme', this.theme); // Save the user's explicit choice
    this.applyTheme();
  }

  applyTheme() {
    document.documentElement.setAttribute('data-theme', this.theme);
  }
}

// Instantiate it
const toggler = new ThemeToggler();

// And your button click handler would simply be:
document.querySelector('#theme-toggle').addEventListener('click', () => {
  toggler.toggle();
});

Avoiding the Flash of Unstyled Content (FOUC)

This is the big one. If you just run this script at the bottom of the page, the user might see a flash of light mode before the JS kicks in and applies dark mode. It’s unprofessional. The solution is to run a tiny piece of crucial JavaScript before the page renders. Stick this in the <head> of your HTML.

<script>
  // Immediately set the theme based on what's in localStorage to prevent FOUC
  (function() {
    const savedTheme = localStorage.getItem('theme');
    const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
    const theme = savedTheme ? savedTheme : (systemPrefersDark ? 'dark' : 'light');
    document.documentElement.setAttribute('data-theme', theme);
  })();
</script>

This script runs synchronously, right at the start. The html element will have the correct data-theme attribute before the browser even starts painting the CSS, so everything loads in the correct theme instantly. It’s a pro move.

The Toggle Button Itself

The button itself needs to be accessible and make sense. Use aria-label if your icon isn’t clear enough, and remember to reflect the state for screen readers.

<button id="theme-toggle" aria-label="Toggle theme">
  <span class="light-mode-icon">☀️</span> <!-- Sun icon -->
  <span class="dark-mode-icon">🌙</span> <!-- Moon icon -->
</button>

Then, in your CSS, you can show/hide the appropriate icon based on the theme. It’s a small touch, but it completes the illusion.

[data-theme="light"] .dark-mode-icon { display: none; }
[data-theme="dark"] .light-mode-icon { display: none; }

And there you have it. A robust, persistent, and respectful dark mode toggle. It remembers the user, it respects the system, and it doesn’t flash like a paparazzi camera. You’ve just built something better than what’s on 90% of the web. Go ahead, pat yourself on the back. You’ve earned it.