Astro transitions are great. Some folks are coming up with some really slick examples of how to use them, and even this little ole blog uses the defaults just to add a little flair when navigating between pages. However, I ran into a little issue when I started to implement dark mode on this site. The dreaded Flash of Unstyled Content (FOUC).

What is the FOUC?

A Flash of Unstyled Content is when the browser renders the page before the CSS has been loaded. The browser will render the page with the default styles, and then apply the CSS once it has been loaded. This can cause a jarring experience for the user, especially if the page is styled differently after the CSS has been loaded (often noticeable when switching between light and dark mode!).

You can see the site initially loads with the white theme in place, then the dark mode preference is read from localstorage, and so the Tailwind dark class is applied and the theme changed. This causes the content to flicker as the styles are swapped out, and can happen during page transitions as well.

Josh Comeau has a great article on the dreaded flicker and how to prevent it, which is well worth a read!

So lets see how we can use that knowledge to prevent the FOUC when using Astro transitions and Tailwind.

The setup

So in this case I’m using a React Astro island for my theme toggle. It attempts to load the current theme into state, and then a useEffect will adjust the document body with the Tailwind class accordingly. (well, small white lie, I’m actually using a nano store but we’ll leave that out to keep things simple!)

ThemeToggle.tsx
1
import { useEffect, useState } from "react";
2
3
/* Simplified theme toggle for FOUC demo */
4
export default function ThemeToggle() {
5
const [theme, setTheme] = useState("");
6
const isDark = theme === "dark";
7
8
useEffect(() => {
9
if (!theme) {
10
setTheme(localStorage?.getItem("theme") || "light");
11
}
12
if (isDark) {
13
document.documentElement.classList.add("dark");
14
} else {
15
document.documentElement.classList.remove("dark");
16
}
17
document.documentElement.dataset.theme = isDark
18
? "dracula"
19
: "github-light";
20
21
localStorage?.setItem("theme", theme);
22
}, [isDark, theme]);
23
24
const handleClick = () => {
25
setTheme(isDark ? "light" : "dark");
26
};
27
28
return (
29
<button onClick={handleClick}>
30
{isDark ? ":new_moon_face:" : ":sun_with_face:"}
31
</button>
32
);
33
}

and here he is, try giving him a click!

This works fine, but we would still have the FOUC when pages transition and on initial load, so let’s fix the page load first.

Page load

To fix the FOUC on page load, we can use a little bit of JavaScript to add a class to the document body when the page is loaded. The reason this works is because we use the Astro directive is:inline to add the script to the page, and it will be executed before the page is rendered. As usually Astro will strip out any script tags, but with is:inline we can keep them in place.

We can place the following code in the head of our document.

page-load-fix.html
1
<script is:inline>
2
function setTheme() {
3
const theme = (() => {
4
if (
5
typeof localStorage !== "undefined" &&
6
localStorage.getItem("theme")
7
) {
8
return localStorage.getItem("theme");
9
}
10
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
11
return "dark";
12
}
13
return "light";
14
})();
15
16
if (theme === "dark") {
17
document.documentElement.classList.add("dark");
18
} else {
19
document.documentElement.classList.remove("dark");
20
}
21
document.documentElement.dataset.theme =
22
theme === "light" ? "github-light" : "dracula";
23
}
24
25
setTheme();
26
</script>

First we setup a function to set the theme which uses a theme function to try and load the current theme from storage, which would be a previously set preference. Failing that, we fallback to match the media query to provide the preference of the user based on their system settings. Lastly, if none of that matches, it would return the 'light' theme.

With the theme value set, we then add the class to the document body, which will apply the theme to the page before render because the script is inline in our <head>, therefore parsed and rendered before the rest of the HTML.

Page transitions

You’ll notice that the page transitions still cause a FOUC (and if you look really closely, you’ll notice the page reload where the font resets itself, is fixed! 🎉). This is because the Astro transitions are replacing the page elements. So when the page transitions, the body class is removed and then reapplied, causing the flicker.

To fix this, we can use some additional Astro events, astro:page-load and astro:after-swap, to listen out for page transitions. That way we can trigger applying the body class correctly in all cases.

page-transition-fix.html
1
<script is:inline>
2
function setTheme() {
3
const theme = (() => {
4
if (
5
typeof localStorage !== "undefined" &&
6
localStorage.getItem("theme")
7
) {
8
return localStorage.getItem("theme");
9
}
10
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
11
return "dark";
12
}
13
return "light";
14
})();
15
16
if (theme === "dark") {
17
document.documentElement.classList.add("dark");
18
} else {
19
document.documentElement.classList.remove("dark");
20
}
21
document.documentElement.dataset.theme =
22
theme === "light" ? "github-light" : "dracula";
23
}
24
25
document.addEventListener("astro:page-load", () => {
26
setTheme();
27
});
28
29
document.addEventListener("astro:after-swap", () => {
30
setTheme();
31
});
32
33
setTheme();
34
</script>

That’s what’s in place on this site, and how it’s working!

Thanks for reading! 💜

Fancy another?