Astro flickering dark mode
How to prevent from flickering
Tags:
What is Astro
To say it with Astro words: Astro is the all-in-one web framework designed for speed Astro is a JavaScript framework for multi-page applications, MPA, that combines a html/javascript syntax with frontmatter. The code between hyphens will be executed at build time, while the JavaScript code between script tags will be executed at runtime.
The problem
Usually, for dark mode, you can use localStorage to set users’ theme preferences. The user will switch between themes by clicking on a button with an on-click callback like this:
if (localStorage.getItem('theme') === 'dark') {
localStorage.setIem('theme', 'light')
} else {
localStorage.setIem('theme', 'dark')
}
A side effect of MPA is that Astro requires fetching a page from the server while jumping between those with links. In the case of dark mode, the server doesn’t know first what is set as theme, dark or light version of the website, by the client. Then, when the page is loaded and script tags are executed client-side, you will have the correct theme.
This can cause a page to flicker, let’s say that you set a dark theme on the homepage and you want to visit another page. By clicking the link you request a new page from the server, Astro pages are built from the content of the page folder and the name of the files make the url. So here we can have a problem server/client state synchronisation: pages first load whit a default theme, let’s say light theme, then switch to dark when the script is executed.
The solution
A possible solution is to add a blocking script before loading the content. How can be achieved this when Astro processes and bundles all the client-side scripts? To avoid bundling you can use the is:inline directive. From Astro documentation:
<script is:inline>
// Will be rendered into the HTML exactly as written!
// Local imports are not resolved and will not work.
// If in a component, repeats each time the component is used.
</script>
So you can add a blocking script before everything else in your layout component and check which theme is set by client
Dark mode with Tailwind and Alpine.js
This is the code I wrote to face this problem and to solve page flickering on my Astro website. Part of the script was replaced with Alpine.js directives and I used Tailwind for styling. I will skip project initialization and dependencies installation, you can refer to Astro doc for this.
First of all, we have to customize tailwind.config.cjs
in order to use data-theme as dark mode selector instead of a dark class.
module.exports = {
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
darkMode: ['class', '[data-theme="dark"]'],
[...]
Now we have to set the correct data-theme in the html tag of our layout. By using Alpine.js directives we implement a dark mode feature that persists in the user’s preference in the browser’s local storage.
<html
lang="en"
x-data="{darkMode: localStorage.getItem('darkMode')}"
x-init="$watch('darkMode', val => localStorage.setItem('darkMode', val))"
x-bind:data-theme="darkMode"
></html>
Here’s an explanation of each directive:
x-data="{darkMode: localStorage.getItem('darkMode')}"
This directive initializes the Alpine.js component’s data object with a property calleddarkMode
. The initial value ofdarkMode
is retrieved from the browser’s local storage usinglocalStorage.getItem('darkMode')
.x-init="$watch('darkMode', val => localStorage.setItem('darkMode', val))"
This directive adds an initialization logic to the component. It watches thedarkMode
property for changes and whenever the value changes, it updates thelocalStorage
with the new value usinglocalStorage.setItem('darkMode', val)
. This ensures that the user’s preference is saved in the local storage.x-bind:data-theme='darkMode'
This directive binds the value of thedarkMode
property to thedata-theme
attribute. WhendarkMode
istrue
, thedata-theme
attribute will have a value of'dark'
. WhendarkMode
isfalse
, thedata-theme
attribute will have a value of'light'
.
Now we need something to toggle between these two themes. We can create a floating button using Tailwind. Create a new component named DarkModeToggle.astro
---
---
<div
id="darkToggle"
class="fixed bottom-10 right-10 dark:bg-gray-200 bg-slate-900 rounded-full h-12 w-12 cursor-pointer flex items-center justify-center z-50 hover:scale-125 transition-all duration-500"
x-on:click="darkMode = (darkMode == 'dark') ? 'light' : 'dark'"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="#0f172a"
class="w-8 h-8 stroke-1"
x-show.transition="darkMode === 'dark'"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
></path>
</svg>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="#e5e7eb"
class="w-8 h-8 stroke-1"
x-show.transition="darkMode !== 'dark'"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"
></path>
</svg>
</div>
With this piece of code, we create a button that set a darkMode value in local storage using the x-on:click
directive that toggles the two values. Based on the darkMode value we show a different icon. This I achieved with the x-show
directive.
Now we have a layout that can switch between a dark theme and a light theme, but we are facing a server/client state synchronisation problem.
Here comes our blocking script. Before everything in our layout we have to add a script with is:inline
directive to avoid bundling.
<script is:inline>
if (localStorage.getItem('darkMode') === 'dark') {
document.querySelector('html').dataset.theme = 'dark'
}
if (localStorage.getItem('darkMode') === 'light') {
document.querySelector('html').dataset.theme = 'light'
}
if (!localStorage.getItem('darkMode')) {
let theme = window.matchMedia('(prefers-color-scheme: dark)')
? 'dark'
: 'light'
localStorage.setItem('darkMode', theme)
}
</script>
- The first
if
statement checks if the value of'darkMode'
in the local storage is'dark'
. If true, it sets thedata-theme
attribute of the<html>
element to'dark'
. This applies the dark theme to the webpage. - The second
if
statement checks if the value of'darkMode'
in the local storage is'light'
. If true, it sets thedata-theme
attribute of the<html>
element to'light'
. This applies the light theme to the webpage. - The third
if
statement is executed when there is no value stored for'darkMode'
in the local storage. It checks the user’s preferred color scheme usingwindow.matchMedia('(prefers-color-scheme: dark)').matches
. If the user prefers a dark color scheme, it setstheme
to'dark'
. Otherwise, it setstheme
to'light'
. Then, it stores this value in the local storage usinglocalStorage.setItem('darkMode', theme)
. This ensures that the user’s preferred theme is saved in the local storage for future visits.