Summit Themes
Blog

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

A two-state toggle—light or dark—is easy to build. The hard part is the third state: system. System mode means "follow whatever the operating system says, and keep following it if the user changes their OS setting." Get it wrong and you end up with a toggle that ignores OS-level changes, or worse, a page that flashes white for a quarter of a second before snapping to dark.

This guide builds the full thing from scratch: no libraries, no frameworks required. It works standalone and integrates cleanly into an Astro project. By the end you will have a toggle that stores the user's preference in localStorage, resolves system preference live via matchMedia, and never flashes the wrong theme on load.

How the three states map to behavior

Before writing code, it helps to be precise about what each state means:

  • Light — always render the light theme, regardless of OS setting.
  • Dark — always render the dark theme, regardless of OS setting.
  • System — render whatever prefers-color-scheme reports right now, and update automatically if the OS setting changes while the page is open.

The key insight: only one attribute drives the CSSdata-theme on the <html> element, set to either "light" or "dark". The user-facing preference (light / dark / system) is a separate concern stored in localStorage. Your JavaScript translates preference → applied theme whenever needed.

Step 1: Set up the CSS

Define your color tokens under each theme state. Here is a minimal example using plain CSS custom properties—no Tailwind required for this part:

/* styles.css */

/* System default: respect the OS */
:root {
  --color-bg: #ffffff;
  --color-text: #111111;
  --color-surface: #f5f5f5;
}

@media (prefers-color-scheme: dark) {
  :root {
    --color-bg: #111111;
    --color-text: #f5f5f5;
    --color-surface: #1e1e1e;
  }
}

/* Explicit overrides via data attribute */
[data-theme="light"] {
  --color-bg: #ffffff;
  --color-text: #111111;
  --color-surface: #f5f5f5;
}

[data-theme="dark"] {
  --color-bg: #111111;
  --color-text: #f5f5f5;
  --color-surface: #1e1e1e;
}

When data-theme is absent, the @media block takes over—that is your system state. When the attribute is present, it wins via specificity. This means you never need to remove the media query rule; you just add or remove the attribute.

If you are using Tailwind CSS v4

Tailwind v4 is fully CSS-first—there is no tailwind.config.js. Register the dark variant with a @custom-variant directive in your CSS file:

/* app.css */
@import "tailwindcss";

@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));

After that, dark: utilities (like dark:bg-zinc-900) activate whenever any ancestor element has data-theme="dark". The system state works exactly as above: when the attribute is absent, Tailwind's built-in @media (prefers-color-scheme: dark) media query still applies—but only if you have not overridden the dark variant. If you want both to coexist, keep the media query fallback in your :root block as shown above.

Step 2: Prevent the flash on page load

This is the step most tutorials skip. If you initialize the theme in a deferred or module script, the browser paints the page first—with whatever the CSS default is—and then your script corrects it. The result is a visible flash.

The fix is a tiny blocking inline script placed at the very top of <head>, before any stylesheets. Inline scripts in <head> block rendering, which is normally bad—but here you want to block until the theme attribute is set.

<!-- Place this as the FIRST child of <head> -->
<script>
  (function () {
    var stored = localStorage.getItem('theme-preference');
    if (stored === 'light' || stored === 'dark') {
      document.documentElement.setAttribute('data-theme', stored);
    }
    // If stored === 'system' or nothing is stored, leave the attribute
    // absent so the CSS media query takes over automatically.
  })();
</script>

Keep this script small—it runs synchronously before anything renders. No window.onload, no DOMContentLoaded. The IIFE wrapper keeps it out of global scope.

In Astro, add this inside your base layout's <head>:

---
// src/layouts/BaseLayout.astro
---
<!DOCTYPE html>
<html lang="en">
  <head>
    <script is:inline>
      (function () {
        var stored = localStorage.getItem('theme-preference');
        if (stored === 'light' || stored === 'dark') {
          document.documentElement.setAttribute('data-theme', stored);
        }
      })();
    </script>
    <!-- rest of head -->
  </head>
  <body>
    <slot />
  </body>
</html>

The is:inline directive tells Astro not to process or bundle this script—it lands in the HTML as-is, which is what you need.

Step 3: The theme manager module

Now the main logic. Create a module you can import wherever you mount your toggle UI:

// src/scripts/theme.js

const STORAGE_KEY = 'theme-preference';
const VALID_THEMES = ['light', 'dark', 'system'];

/**
 * Returns the user's stored preference, or 'system' as the default.
 */
function getStoredPreference() {
  const stored = localStorage.getItem(STORAGE_KEY);
  return VALID_THEMES.includes(stored) ? stored : 'system';
}

/**
 * Resolves a preference to an applied theme ('light' or 'dark').
 * When preference is 'system', reads the current OS setting.
 */
function resolveTheme(preference) {
  if (preference === 'light') return 'light';
  if (preference === 'dark') return 'dark';
  return window.matchMedia('(prefers-color-scheme: dark)').matches
    ? 'dark'
    : 'light';
}

/**
 * Applies the resolved theme to the document root.
 * Removes the attribute entirely in system mode so the CSS
 * media query takes over—but sets it explicitly for light/dark.
 */
function applyTheme(preference) {
  const html = document.documentElement;
  if (preference === 'system') {
    html.removeAttribute('data-theme');
  } else {
    html.setAttribute('data-theme', preference);
  }
}

/**
 * Saves a preference and applies it immediately.
 */
function setPreference(preference) {
  if (!VALID_THEMES.includes(preference)) return;
  localStorage.setItem(STORAGE_KEY, preference);
  applyTheme(preference);
}

/**
 * Call once during app init. Sets up the OS-change listener.
 * Returns the current preference string.
 */
function initTheme() {
  const preference = getStoredPreference();
  applyTheme(preference);

  // Listen for OS-level changes; only act when user is in system mode.
  window
    .matchMedia('(prefers-color-scheme: dark)')
    .addEventListener('change', function () {
      const current = getStoredPreference();
      if (current === 'system') {
        applyTheme('system');
      }
    });

  return preference;
}

export { initTheme, setPreference, getStoredPreference };

A few things worth noting in this design. applyTheme removes data-theme entirely for system mode rather than setting it to a computed value—this keeps the CSS @media rule live and responsive. If you set data-theme="dark" to represent system-dark, the attribute wins over the media query and OS changes stop updating the page. The matchMedia listener only fires when the OS switches; it checks the stored preference first so explicit light/dark choices are never overridden.

Step 4: Wire up the toggle UI

The simplest UI is three buttons grouped together. Here is a self-contained example with no framework:

<!-- toggle.html -->
<div role="group" aria-label="Color theme">
  <button data-theme-btn="light" aria-pressed="false">Light</button>
  <button data-theme-btn="dark"  aria-pressed="false">Dark</button>
  <button data-theme-btn="system" aria-pressed="false">System</button>
</div>

<script type="module">
  import { initTheme, setPreference, getStoredPreference } from '/src/scripts/theme.js';

  function updateButtons(activePreference) {
    document.querySelectorAll('[data-theme-btn]').forEach(function (btn) {
      var isActive = btn.dataset.themeBtn === activePreference;
      btn.setAttribute('aria-pressed', isActive ? 'true' : 'false');
    });
  }

  var currentPreference = initTheme();
  updateButtons(currentPreference);

  document.querySelectorAll('[data-theme-btn]').forEach(function (btn) {
    btn.addEventListener('click', function () {
      var preference = btn.dataset.themeBtn;
      setPreference(preference);
      updateButtons(preference);
    });
  });
</script>

The aria-pressed attribute communicates the active state to screen readers without you needing to manage a separate "active" class. You can style the active button with [aria-pressed="true"] in your CSS—no JavaScript class toggling required.

Step 5: Handling the Astro view transitions edge case

If your Astro site uses <ViewTransitions />, the inline head script runs once on the initial load but not on subsequent client-side navigations. The theme can reset between pages.

Fix this by listening for Astro's navigation events and re-applying the theme:

// src/scripts/theme-init.js
import { initTheme } from './theme.js';

// Initial load
initTheme();

// Re-apply after each Astro client-side navigation
document.addEventListener('astro:after-swap', function () {
  var { applyTheme, getStoredPreference } = await import('./theme.js');
  // Simpler: just call initTheme again (idempotent)
  initTheme();
});

Or, more simply, add the inline head script to the astro:after-swap listener directly inside the is:inline script so it re-runs after each navigation without any module overhead:

<script is:inline>
  function applyStoredTheme() {
    var stored = localStorage.getItem('theme-preference');
    var html = document.documentElement;
    if (stored === 'light' || stored === 'dark') {
      html.setAttribute('data-theme', stored);
    } else {
      html.removeAttribute('data-theme');
    }
  }

  applyStoredTheme();
  document.addEventListener('astro:after-swap', applyStoredTheme);
</script>

Common mistakes to avoid

  • Setting data-theme to the resolved value in system mode. If you write data-theme="dark" when the OS is dark, the attribute overrides the media query and changes to the OS no longer affect the page. Remove the attribute; let the CSS media query do its job.
  • Initializing the theme in a deferred or module script. Any type="module" or defer attribute causes the script to run after parsing, which is too late to prevent a flash.
  • Not listening for OS changes. If a user switches their OS from light to dark while your page is open and they are in system mode, nothing should happen unless you have the matchMedia change listener in place.
  • Forgetting aria-pressed or similar state indicators. Keyboard and screen reader users need to know which option is currently active.

Putting it together

The complete pattern is four pieces: an inline blocking script in <head> that reads localStorage and sets the attribute before the first paint; CSS custom properties (or Tailwind v4's @custom-variant) that react to data-theme; a small theme module that handles persistence and the OS change listener; and a simple button group that calls setPreference on click. None of it requires a library, and the whole thing survives full page reloads, client-side navigation, and mid-session OS changes without a flicker.