Dark Mode Color Scheme Transitions Without Flicker (FOUC)
AI generated
x-data
Alpine
Alpine.js / Styling
Dark Mode Transitions Without Flicker
how to avoid the Flash of Unstyled Content

A dark mode toggle that only kicks in after Alpine initializes briefly shows the wrong color scheme on page load before it switches over. This Flash of Unstyled Content is not an Alpine-specific problem, it is a question of ordering: whoever sets the theme before the first visible frame instead of after gets rid of the flicker entirely, without any extra library.

9 min read Dark Mode FOUC localStorage

1. Where the flicker on page load actually comes from

A typical dark mode toggle reads the stored preference from localStorage inside an x-data component and sets a class on html based on it. The problem: Alpine only initializes its components after the browser has already parsed the HTML and rendered a first frame, usually after the DOMContentLoaded event, or once Alpine's own script has loaded and executed.

Between that first visible frame, rendered with the default styles and no dark class, and the moment Alpine reads the stored preference and applies the class afterwards, there is a short but noticeable gap. Right in that gap the user sees the wrong color scheme flash before it switches to the stored one, an effect that is especially obvious when a deliberately dark preference briefly shows a light default page.

2. The fix: an inline script that runs before Alpine

The reliable fix sits outside Alpine: a tiny, synchronously executed script right in the head, before Alpine.js even loads, reads the stored preference and sets the dark class on html before the browser even starts rendering visible content. Since scripts in head without defer or async run blocking and synchronously, the class is already decided before the first pixel is painted.

Alpine itself doesn't need to know anything about this script, it just reads the already-set state off the html element during its own init instead of recomputing it. That way the inline script handles exactly one time-critical job, establishing the correct starting state before the first frame, while Alpine stays responsible for the toggle's later interactivity.


<head>
  <!-- Must sit before any CSS/JS that depends on the theme -->
  <script>
    (function () {
      var stored = localStorage.getItem('theme');
      var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
      var isDark = stored === 'dark' || (!stored && prefersDark);
      document.documentElement.classList.toggle('dark', isDark);
    })();
  </script>
  <!-- Alpine.js loads only after this -->
  <script defer src="alpinejs@3.x.x.js"></script>
</head>

3. The Alpine toggle component: responsible for interaction only

Once the inline script has established the correct starting state, the Alpine component's job shrinks to two things: mirroring the current state for the UI, say the toggle button's icon, and on click updating both the DOM class and the stored value in localStorage. Recomputing the initial preference during Alpine's init is no longer necessary, since that work already happened before the first frame.

It matters to read the Alpine component's initial state off the already-set DOM state, not to recompute it from localStorage a second time. Otherwise a second, more subtle failure mode appears: if the Alpine component's logic drifts even slightly from the inline script's logic, say in how it handles prefers-color-scheme, both places can land on different results.


Alpine.data('themeToggle', () => ({
  isDark: document.documentElement.classList.contains('dark'),
  toggle() {
    this.isDark = !this.isDark;
    document.documentElement.classList.toggle('dark', this.isDark);
    localStorage.setItem('theme', this.isDark ? 'dark' : 'light');
  },
}));

4. Working together with prefers-color-scheme as a fallback

As long as a user has not made an explicit choice yet, meaning no value is stored in localStorage, the system preference signal prefers-color-scheme: dark should act as the fallback. The inline script from the second section already models that priority correctly: stored preference before system preference, system preference before a hard default.

An additional nuance concerns users who change their system setting while the page is already open, without ever having actively used the toggle themselves. A window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', ...) listener can follow the theme live in that case, but should be disabled once the user has explicitly stored their own preference via the toggle, so the deliberate user choice keeps priority over the system.

5. Smooth CSS transitions for the actual color switch

Once the flicker on initial load is solved, the question remains how the switch feels on an active toggle click. An instant, hard color jump often feels more abrupt than needed, while a short transition on background-color, color, and border-color makes the switch feel like a deliberate, controlled action instead of a technical jump.

It matters to deliberately scope this transition rule to the active click only, not to the initial page load, since a transition that also applies on first render could reintroduce a weakened form of the FOUC problem the inline script already solved, by visibly cross-fading the correctly set state instead of showing it immediately.


html.theme-transitions-enabled,
html.theme-transitions-enabled * {
  transition: background-color 200ms ease, color 200ms ease, border-color 200ms ease;
}

6. A common misconception: x-cloak does not solve the FOUC problem

x-cloak hides elements until Alpine has initialized, preventing a brief flash of unprocessed x-data templates. For the dark mode flicker, though, that is the wrong tool, because x-cloak acts on individual elements while the color scheme FOUC problem affects the entire page, including every background color already rendered before any Alpine initialization runs.

Anyone trying to suppress the flicker by putting x-cloak on the body element ends up hiding the entire page for the duration of Alpine's initialization in the worst case, which doesn't fix the actual problem, the wrong order of rendering and theme determination, it just masks it while introducing new wait time.

7. CSP registration in the Hyvä context

In a Hyvä theme with a strict Content Security Policy, the critical inline script from the second section also needs to be registered regularly through the CSP component so the policy doesn't block it. The decisive point here is purely the placement in markup, the script has to be included in the layout XML as early as possible in head, well before the Alpine bundle.

Since the script is only a few lines and has no external dependencies, it works well as its own minimal block right in the head template, instead of embedding it into a larger, later-loaded JavaScript file that would otherwise undo the time-critical advantage.

8. How to reliably test the FOUC fix

The best test is unglamorous but effective: explicitly set the dark mode preference to dark, then throttle the network in the browser to a slow connection and reload the page. Without the fix, a clearly visible light flash shows up before the switch to dark on the slow connection, with the correctly placed inline script the page stays dark throughout from the very start.

It also pays to test with JavaScript disabled in the browser: since the inline script sits synchronously in head, at least the basic class on html should already be visible in the initially delivered HTML if a stored preference is honored server-side via a cookie instead of only localStorage, which is the only path to a correct first render for users without JavaScript enabled.

9. A checklist for a FOUC-free dark mode toggle

A reliable dark mode toggle meets five points: the theme-determining script sits synchronously without defer in head, it sits before the Alpine bundle, it reads stored preference before system preference before a default in exactly that order, the Alpine component reads its initial state from the already-set DOM instead of recomputing it, and CSS transitions for the color switch are deliberately decoupled from the initial page load.

Anyone implementing all five points needs no extra library and no complex server-side rendering to fully avoid the flicker, since the entire problem can be solved with nothing more than the right ordering of a few lines of vanilla JavaScript.

Approach When the theme is set FOUC visible? Extra effort
Alpine x-data only, on init After first frame, after DOMContentLoaded Yes, clearly visible None, but user experience suffers
x-cloak on body After Alpine init, visibility delayed No, but page stays blank/delayed New wait time instead of a real fix
Inline script in head before Alpine Before the first visible frame No A few lines of vanilla JS, one time
Server-side cookie + inline script Already in the initially delivered HTML No, even without JavaScript Requires additional server logic

Mironsoft

Alpine.js interactivity for Hyvä frontends

A Hyvä frontend that needs more interactivity, but without React overhead?

We build interactive frontend components for Hyvä themes with Alpine.js, lightweight and without build-step complexity, from simple toggles to complex form flows.

Custom Components

Develop interactive Alpine.js components for specific shop requirements.

Performance Review

Review existing Alpine.js implementations for reactivity pitfalls and performance.

Team Training

Bring developers up to speed on Alpine.js patterns for Hyvä themes hands-on.

10. Summary

Dark mode without flicker

Core idea

FOUC happens because Alpine sets the theme only after the first visible frame, a synchronous inline script before Alpine fixes it.

Practical benefit

The Alpine component reads its initial state from the already-set DOM instead of recomputing it.

Biggest pitfall

x-cloak does not solve the problem since it only affects individual elements, while FOUC affects the whole page including background colors.

Recommendation

Set priority in exactly this order: stored preference, system preference via prefers-color-scheme, default value.

11. FAQ: Dark mode without flicker

1What exactly is Flash of Unstyled Content in dark mode?
It's the brief moment where the page renders in the wrong color scheme before JavaScript has read the stored preference and switched over. The user sees a visible flash of the undesired theme.
2Why isn't an Alpine x-data component alone enough?
Because Alpine only initializes its components after the browser has already rendered a first frame with the default styles. The time until Alpine initializes is enough for the wrong color scheme to become visible.
3Where exactly does the inline script need to sit in the HTML?
As early as possible in head, before any CSS or JavaScript that depends on the theme, and specifically before Alpine.js itself loads. It must not carry a defer or async attribute so it runs synchronously and blocking.
4Does x-cloak solve the dark mode FOUC problem?
No, x-cloak hides individual elements until Alpine initializes, but it doesn't affect the page's basic rendering with default background colors, which already happens before any Alpine execution.
5How should prefers-color-scheme be prioritized correctly?
An explicitly stored user preference always takes priority over the system setting. Only if no stored value exists does prefers-color-scheme decide, and only if that is unavailable too does a hard default apply.
6Should I follow system changes live while the page is open?
Only as long as the user hasn't stored their own preference yet. A change listener on prefers-color-scheme can adjust the theme live in that case, but should be disabled once the user has actively used the toggle.
7How do I register the critical inline script under Hyvä CSP?
Like any other inline script, regularly through the CSP component, the decisive point is purely the placement in the layout XML as early as possible in head, well before the Alpine bundle.
8Should the color switch be animated on click?
A short CSS transition on background-color, color, and border-color makes the deliberate switch feel nicer, but should apply only to the active click, not to the initial page load, to avoid introducing new flicker.
9How do I reliably test whether the FOUC fix works?
The most telling test is throttling the network connection with the preference explicitly set to dark, a visible light flash before the switch to dark shows the ordering still isn't right.
10Does the approach work without JavaScript enabled for the user?
The pure inline script approach does not, since it requires JavaScript. Full support without JavaScript additionally needs a server-side evaluation of a stored cookie already in the delivered HTML.