Dark Mode Toggle with $persist and System Preference
AI generated
x-data
Alpine
Alpine.js · Dark Mode · $persist · System Preference · Tailwind CSS
Dark Mode Toggle with $persist
and System Preference

A dark mode that resets on the next visit, or one that briefly flashes light before turning dark on load: those are the two most common mistakes. Alpine.js solves both. $persist stores the user preference permanently in localStorage, and a blocking script placed before Alpine.js prevents the flash of wrong theme.

14 min read $persist · prefers-color-scheme · FOWT · Tailwind dark: Alpine.js 3.x · Tailwind CSS v4 · LocalStorage

1. Dark mode: three layers, system, user preference, class

A complete dark mode toggle in Alpine.js is made up of three layers stacked on top of each other. The bottom layer is the system preference: prefers-color-scheme: dark indicates whether the user's operating system has dark mode enabled. Above that sits the stored user preference: if the user has explicitly chosen a mode on the website, it takes precedence regardless of the system setting. The top layer is the CSS class on the <html> element: class="dark" activates every dark: variant in Tailwind CSS.

What makes this system tricky is timing: the CSS class must be set before the browser renders the very first pixel. If Alpine.js only initializes after the page has loaded and sets the class then, the user briefly sees the wrong variant. This is the notorious flash of wrong theme (FOWT), the visible switch from light to dark right after the page loads. The fix is a small blocking script in the page's <head> that reads the localStorage values and sets the class synchronously, before CSS and Alpine.js are even loaded.

This is especially relevant in Hyva projects: Hyva renders pages server side, and the browser starts rendering as soon as it receives the first bytes. An Alpine.js based dark mode without a blocking script will always produce a FOWT in Hyva, because Alpine.js only initializes after the page's initial render. The blocking script approach is therefore not an optional optimization but a mandatory part of a solid dark mode implementation.

2. $persist: storing the user preference permanently

Alpine.js $persist is a magic property from the @alpinejs/persist plugin that automatically syncs variables to localStorage. When a variable is declared with $persist, every change is written to localStorage immediately and read back on the next page visit. For dark mode that means this.dark = this.$persist(null), where null means no explicit user preference has been set yet (system default).

The localStorage key can be customized with .$as('keyName'): this.dark = this.$persist(null).$as('colorScheme'). This matters for projects with multiple Hyva themes or multiple Alpine.js instances sharing the same localStorage. Without an explicit key, $persist uses the property name as the key, which can collide with generic names like dark.

// Blocking script in the <head> (load before Alpine.js!)
// Prevents the flash of wrong theme, runs synchronously during parsing
(function () {
  const stored = localStorage.getItem('colorScheme');
  const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
  const isDark = stored === 'dark' || (stored === null && systemDark);
  if (isDark) {
    document.documentElement.classList.add('dark');
  }
})();

// Alpine.js dark mode component with $persist
// File: Mironsoft_Theme/templates/page/js/dark-mode.phtml
function darkModeToggle() {
  return {
    // $persist syncs with localStorage, key: 'colorScheme'
    // null = system default, 'light' = explicit light, 'dark' = explicit dark
    preference: null,

    init() {
      // Set up $persist (initialized in the Alpine.js context via x-init)
      this.preference = this.$persist(null).$as('colorScheme');

      // Watch for system preference changes at runtime
      const mql = window.matchMedia('(prefers-color-scheme: dark)');
      mql.addEventListener('change', (e) => {
        if (this.preference === null) {
          this.applyTheme(e.matches ? 'dark' : 'light');
        }
      });

      // Apply on init
      this.applyCurrentTheme();

      // Apply the theme whenever preference changes
      this.$watch('preference', () => this.applyCurrentTheme());
    },

    get isDark() {
      if (this.preference === 'dark') return true;
      if (this.preference === 'light') return false;
      // null = system default
      return window.matchMedia('(prefers-color-scheme: dark)').matches;
    },

    applyCurrentTheme() {
      this.applyTheme(this.isDark ? 'dark' : 'light');
    },

    applyTheme(theme) {
      document.documentElement.classList.toggle('dark', theme === 'dark');
    },

    toggle() {
      this.preference = this.isDark ? 'light' : 'dark';
    },

    setSystem() {
      this.preference = null; // null = use system default
    }
  };
}

3. prefers-color-scheme as the system default

The prefers-color-scheme media feature is the CSS and JavaScript interface into the operating system setting. In JavaScript you read it with window.matchMedia('(prefers-color-scheme: dark)').matches. This value is available synchronously at call time, no promise, no event wait. That makes it ideal for the blocking script in the <head>.

To observe it at runtime you use addEventListener('change', ...) on the MediaQueryList object. If the user changes the system setting during the browser session, this event fires. The Alpine.js component reacts to it, but only when the user has not set an explicit preference (preference === null). If the user has manually chosen a mode, the system change must not override it.

4. Preventing the flash of wrong theme (FOWT)

The flash of wrong theme happens because the browser parses the HTML, applies CSS, renders the page initially in its light mode version, and only executes JavaScript afterwards. When Alpine.js then sets the dark class, the browser performs a repaint, and the user briefly sees the light version before the dark one appears. On fast connections this takes milliseconds; on slower devices or with a lot of CSS it can be several hundred milliseconds of visible flicker.

The fix is a synchronous blocking script placed directly in the page's <head>, before the first stylesheet. This script reads localStorage and sets the class synchronously while the browser is still parsing HTML, before it even begins rendering. The script must stay minimal (no module, no defer, no async) so it does not stall parsing. In Hyva the script is inserted into the <head> via layout XML, with CSP registration.

5. Orchestrating Tailwind CSS dark: classes

Tailwind CSS v4 supports dark mode through the dark: variant, which activates when the <html> element carries the dark class (selector strategy). In the Tailwind configuration this is enabled with darkMode: 'class'. Classes like dark:bg-slate-900 dark:text-white then work automatically as soon as <html class="dark"> is set.

In Hyva projects using Tailwind CSS v4 (CSS first approach), the dark mode configuration lives in the CSS file. This also allows the use of CSS custom properties for dark mode colors, a cleaner architecture than using direct utility classes everywhere. The Alpine.js component only orchestrates the dark class on <html>; the CSS takes care of every visual consequence.

// Three state toggle: system | light | dark
// Extended version with an explicit system mode
function threeStateTheme() {
  return {
    // 'system' | 'light' | 'dark'
    mode: 'system',

    init() {
      this.mode = this.$persist('system').$as('themeMode');
      this.applyTheme();
      this.$watch('mode', () => this.applyTheme());

      // Watch for system changes
      window.matchMedia('(prefers-color-scheme: dark)')
        .addEventListener('change', () => {
          if (this.mode === 'system') this.applyTheme();
        });
    },

    applyTheme() {
      let dark = false;
      if (this.mode === 'dark') dark = true;
      else if (this.mode === 'system') {
        dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
      }
      document.documentElement.classList.toggle('dark', dark);
    },

    // Getter for UI display
    get label() {
      const labels = { system: 'System', light: 'Light', dark: 'Dark' };
      return labels[this.mode];
    },

    get icon() {
      if (this.mode === 'dark') return 'moon';
      if (this.mode === 'light') return 'sun';
      return 'monitor'; // system
    },

    // Cycle through the three states
    cycle() {
      const order = ['system', 'light', 'dark'];
      const next = (order.indexOf(this.mode) + 1) % order.length;
      this.mode = order[next];
    },

    setMode(newMode) {
      this.mode = newMode;
    }
  };
}

6. Toggle UI: icons, transitions and state display

The dark mode toggle UI consists of a button that shows the current state and switches between modes on click. The most common pattern: a sun icon for light mode, a moon icon for dark mode, swapped with x-show and x-transition. A three state toggle adds a monitor icon for system mode.

Accessibility matters a lot for the toggle button: the icon alone is not an accessible name. :aria-label="'Theme: ' + label" gives screen readers a spoken button name describing the current mode. aria-pressed is not ideal for this use case because it implies a binary toggle. Instead, aria-label fully describes the state: "Theme: Dark" is more precise than "pressed or not pressed".

7. Three state toggle: light, dark, system

A simple binary dark mode toggle always overrides the system preference. A well thought out UX pattern offers three states: explicit light, explicit dark, and follow system. In follow system mode the app follows the operating system setting; when the user's device switches to dark mode in the evening, the app follows along. For many users this is the preferred mode, because it respects the system preference without requiring any manual setting.

In Alpine.js the three state toggle is an extension of the normal pattern: the preference variable has three possible values ('light', 'dark', and null or 'system'), and the theme application logic checks the explicit preference first, then the system setting. With $persist the chosen mode is saved, and the same three state choice is restored on the next visit.

8. Detecting a system switch at runtime

Modern operating systems can switch the color scheme automatically or manually; Windows 10/11, macOS and iOS all support this. If a user switches their system from light to dark while the browser session is running, a correct Alpine.js dark mode implementation should react to it. This happens via the change event on the MediaQueryList object.

The reaction to the system switch should only happen when the user has not set an explicit preference. If a user has manually turned dark mode off, the system theme must not turn it back on. Only when preference === null (follow system mode) should the system switch flip the theme. This logic is easy to implement in the Alpine.js component: if (this.preference === null) { this.applyTheme(); }.

Problem Anti pattern Solution Explanation
FOWT Alpine.js sets the dark class Blocking script in <head> Runs before the first render
Persistence sessionStorage or cookie $persist with localStorage Permanent, works across tabs
System default Always defaults to light mode Read prefers-color-scheme Respects the OS setting
Tailwind dark media strategy class strategy (darkMode: 'class') Controllable via JavaScript
Runtime switch No listener on the MQL mql.addEventListener('change') Reacts to OS changes

Mironsoft

Alpine.js, Tailwind CSS and Hyva theme development

Need dark mode for your Hyva project?

We implement dark mode without FOWT, with a persistent user preference, follow system mode and full Tailwind integration, for Hyva themes and Magento 2.

Dark mode implementation

FOWT free implementation with $persist, a blocking script and Tailwind dark:

Design system

CSS custom properties and Tailwind configuration for a consistent dual theme

Hyva integration

CSP compliant layout XML integration across every Hyva theme page

10. Summary

A complete dark mode with Alpine.js consists of four components: the blocking script in <head> against FOWT, $persist for a permanent user preference, the prefers-color-scheme listener for follow system mode, and the dark class on <html> as the Tailwind trigger. None of these components is optional; leaving one out causes visible problems.

The three state toggle (light, dark, system) is the best UX for dark mode, because it gives the user both manual control and the option to follow the system setting. With Alpine.js $persist, persistence is trivial to implement. The blocking script is the technically most demanding part, but without it a FOWT always occurs, leaving users with the impression of a janky, poorly built website.

Dark mode with Alpine.js: the essentials at a glance

Blocking script against FOWT

Synchronous script in <head> before CSS. Reads localStorage, sets the dark class on <html>. Runs before the first pixel is rendered.

$persist for persistence

$persist(null).$as('colorScheme'), null means system default. Syncs automatically with localStorage. No manual storage management needed.

Follow system mode

Read prefers-color-scheme. MQL addEventListener('change') for runtime switches. Only react when preference === null.

Tailwind integration

darkMode: 'class' in the Tailwind configuration. dark: variants on every element. document.documentElement.classList.toggle('dark', isDark).

11. FAQ: dark mode with Alpine.js and $persist

1What is a flash of wrong theme?
A brief flash of the wrong theme variant while loading. Happens when JavaScript sets the dark class only after the initial render. A blocking script in the head prevents it.
2What does $persist do?
Automatically syncs Alpine.js variables with localStorage. Every change is saved immediately and restored on the next visit.
3darkMode: 'class' vs. 'media' in Tailwind?
media only follows the system preference. class allows a JavaScript controlled toggle. Always use the class strategy for manual switching.
4Preventing FOWT in Hyva?
A synchronous blocking script in the head, before the first stylesheet. Read localStorage, set classList.add('dark') synchronously during HTML parsing.
5How do I implement follow system mode?
preference === null means follow system. Read prefers-color-scheme. An MQL change listener handles runtime switches. A manual choice overrides system mode.
6Combining $persist with cookies for SSR?
For SSR scenarios use cookies instead of localStorage. $persist and the blocking script are optimized for client rendering like Hyva. A cookie value can be read server side on the first request.
7localStorage not available?
$persist falls back to in memory state. Use a try catch around localStorage in the blocking script. The component still works for the current session without persistence.
8Testing dark mode correctly?
Switch the system preference in OS settings or in the browser DevTools rendering tab (emulate prefers-color-scheme). Manipulate localStorage directly to test FOWT behavior.
9Tailwind dark: classes, examples?
dark:bg-slate-900, dark:text-white, dark:border-slate-700. With darkMode: 'class', the dark class on html activates every dark: variant automatically.
10Blocking script and Hyva CSP?
Yes, $hyvaCsp->registerInlineScript() is required for the blocking script in the head too. It applies to every inline script block in Hyva templates without exception.