How to Add a Dark Mode Toggle to Your Website with CSS Variables and JavaScript

Adding a dark mode toggle to a website is one of those small features that has a huge impact on user experience. Done right, it respects the visitor’s system preference, remembers their choice between visits, and never flashes the wrong theme on page load.

In this tutorial, we’ll build a clean, accessible dark mode toggle using CSS variables and JavaScript, the prefers-color-scheme media query, and localStorage for persistence. No frameworks, no dependencies, just a working pattern you can drop into any project.

What we’re building

  • A theme that uses CSS custom properties for colors
  • Automatic detection of the user’s system theme via prefers-color-scheme
  • A toggle button that switches between light and dark
  • Persistence with localStorage so the choice survives refreshes
  • A no-flash technique that applies the theme before the page renders
dark mode toggle

Step 1: Define your colors with CSS variables

The foundation of a maintainable theme system is CSS custom properties. Instead of hardcoding colors in every rule, you declare them once on :root and reference them everywhere.

:root {
  --bg: #ffffff;
  --text: #1a1a1a;
  --accent: #2563eb;
  --border: #e5e7eb;
}

[data-theme="dark"] {
  --bg: #0f1115;
  --text: #f3f4f6;
  --accent: #60a5fa;
  --border: #2a2f3a;
}

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

a { color: var(--accent); }
.card { border: 1px solid var(--border); padding: 1rem; border-radius: 8px; }

The trick: we toggle the theme by adding data-theme="dark" to the <html> element. Every variable updates instantly because of CSS cascade.

Step 2: Respect the system preference

Many users already set a system-wide preference. We should honor it by default. Add this block so dark colors apply automatically when no manual choice has been made:

@media (prefers-color-scheme: dark) {
  :root:not([data-theme]) {
    --bg: #0f1115;
    --text: #f3f4f6;
    --accent: #60a5fa;
    --border: #2a2f3a;
  }
}

The :not([data-theme]) selector ensures the system preference only applies until the user makes an explicit choice with the toggle.

dark mode toggle

Step 3: Add the toggle button HTML

<button id="theme-toggle" type="button" aria-label="Toggle dark mode" aria-pressed="false">
  <span class="icon-light">☀️</span>
  <span class="icon-dark">🌙</span>
</button>

A few quick styles:

#theme-toggle {
  background: transparent;
  border: 1px solid var(--border);
  color: var(--text);
  cursor: pointer;
  padding: 0.5rem 0.75rem;
  border-radius: 8px;
  font-size: 1rem;
}

[data-theme="dark"] .icon-light { display: none; }
:root:not([data-theme="dark"]) .icon-dark { display: none; }

Step 4: The JavaScript logic

Here’s the full script. Place it before the closing </body> tag:

(function () {
  const STORAGE_KEY = 'theme';
  const root = document.documentElement;
  const btn = document.getElementById('theme-toggle');

  function applyTheme(theme) {
    if (theme === 'dark' || theme === 'light') {
      root.setAttribute('data-theme', theme);
    } else {
      root.removeAttribute('data-theme');
    }
    if (btn) {
      const isDark = theme === 'dark' || (!theme && matchMedia('(prefers-color-scheme: dark)').matches);
      btn.setAttribute('aria-pressed', String(isDark));
    }
  }

  const saved = localStorage.getItem(STORAGE_KEY);
  applyTheme(saved);

  if (btn) {
    btn.addEventListener('click', function () {
      const current = root.getAttribute('data-theme')
        || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
      const next = current === 'dark' ? 'light' : 'dark';
      localStorage.setItem(STORAGE_KEY, next);
      applyTheme(next);
    });
  }

  matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function (e) {
    if (!localStorage.getItem(STORAGE_KEY)) {
      applyTheme(null);
    }
  });
})();
dark mode toggle

Step 5: Eliminate the dreaded theme flash

If the script runs after the body paints, dark mode users will see a quick flash of white. The fix is an inline script in the <head> that runs synchronously before any rendering:

<script>
  (function () {
    try {
      var t = localStorage.getItem('theme');
      if (t === 'dark' || t === 'light') {
        document.documentElement.setAttribute('data-theme', t);
      }
    } catch (e) {}
  })();
</script>

Put this as high as possible in your <head>, ideally right after the <meta charset> tag.

How the three layers work together

Layer Role When it triggers
CSS variables Hold all themed colors Always
prefers-color-scheme Default theme No saved choice
localStorage + JS Manual override, persistence User clicks the toggle
Inline head script Prevents flash on load Before first paint
dark mode toggle

Common mistakes to avoid

  1. Toggling a class on body instead of html. Some elements style themselves before body is parsed. Always target document.documentElement.
  2. Forgetting the inline script. Without it, your dark mode visitors will see a flash of light theme on every navigation.
  3. Hardcoding colors outside variables. Any rule that uses a literal hex code will break in the opposite theme.
  4. Ignoring accessibility. Always set aria-pressed and aria-label on the toggle so screen readers announce the state.
  5. Not testing contrast. Dark mode is not just inverted colors. Check WCAG contrast ratios on both themes.

Going further

  • Add a third “system” option alongside light and dark for users who want to follow their OS
  • Animate the icon swap with a small CSS transition or an SVG morph
  • Theme images with filter: brightness(.85) in dark mode if they look too bright
  • Sync the theme across browser tabs by listening for the storage event

FAQ

Should I use a class or a data attribute to toggle the theme?

Both work. We prefer data-theme because it expresses intent more clearly and avoids conflicts with utility class systems like Tailwind.

Why use CSS variables instead of two separate stylesheets?

One stylesheet means one source of truth. Adding a new themed color is a single line, and the transition between themes is instant with no extra HTTP request.

Does this work without JavaScript?

Yes. The prefers-color-scheme media query alone gives users the correct theme based on their system. JavaScript only adds the manual toggle and persistence.

How do I prevent the flash of unstyled theme (FOUT)?

Use the inline blocking script in the <head> shown in Step 5. It reads localStorage and sets the attribute before the browser paints anything.

Is localStorage the best place to save the preference?

For a client-side toggle, yes. It’s synchronous, simple, and survives reloads. For logged-in users, store it server-side too so the preference follows them across devices.

Will this work in all modern browsers?

Yes. CSS custom properties, prefers-color-scheme, and localStorage are supported in every evergreen browser. No polyfill needed.

Wrapping up

That’s the entire pattern. A handful of CSS variables, a media query, a small script, and an inline head guard. Copy the snippets, adapt the colors to your brand, and you have a persistent dark mode toggle that respects user preferences and feels native.

If you need help shipping polished frontend features like this on your product, the team at PixelFabs can lend a hand. Reach out and let’s build something great.

Leave a Comment