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.
Table of Contents
- 1. Where the flicker on page load actually comes from
- 2. The fix: an inline script that runs before Alpine
- 3. The Alpine toggle component: responsible for interaction only
- 4. Working together with prefers-color-scheme as a fallback
- 5. Smooth CSS transitions for the actual color switch
- 6. A common misconception: x-cloak does not solve the FOUC problem
- 7. CSP registration in the Hyvä context
- 8. How to reliably test the FOUC fix
- 9. A checklist for a FOUC-free dark mode toggle
- 10. Summary
- 11. FAQ
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.