Fixing the "dark mode flash" issue on server rendered websites

Apr 16 2020

/ 3 min read /

0 Likes β€’

0 Replies β€’

0 Reposts

This blog post is a follow up to Switching off the lights - Adding dark mode to your React app that I wrote a year ago. I finally took the time to fix my implementation which caused a lot of issues on server rendered websites and I wanted to share my solution with you.

An ugly hack

When I first added dark mode on my Gatsby projects, I encountered what you might know as the "Dark mode flashing" issue. The colors of the light mode would show up for a brief moment when refreshing a webpage.

Gif showcasing the dark mode flash issue on this blog.

Why does this issue show up? @JoshWComeau explains the reason behind this issue pretty well on his blog post CSS Variables for React Devs:

"Dark Mode" is surprisingly tricky, especially in a server-rendered context (like with Gatsby or Next.js). The problem is that the HTML is generated long before it reaches the user's device, so there's no way to know which color theme the user prefers.

To avoid this issue back when implementing it for the first time I did what I'd call an "ugly hack". I'd avoid rendering the whole website until the theme to render was known, and in the meantime, I'd just render a simple <div/>:

Code snippet from my first dark mode article featuring the ugly hack to avoid "dark mode flash"

1
if (!themeState.hasThemeLoaded) {
2
/*
3
If the theme is not yet loaded we don't want to render
4
this is just a workaround to avoid having the app rendering
5
in light mode by default and then switch to dark mode while
6
getting the theme state from localStorage
7
*/
8
return <div />;
9
}
10
const theme = themeState.dark ? theme('dark') : theme('light');

This ugly hack caused me some of the most frustrating problems I've had in a while, one of them even took me several days to figure out:

(Again thank you @chrisbiscardi for taking the time to help me debug this)

I then brought another solution to this problem: add a display: hidden CSS style to the main wrapper until the theme was loaded as featured in this blog post. It fixed my SEO issues, but I was still not satisfied with this fix.

After reading Josh Comeau's blog post on using CSS variables along with Emotion Styled Components, I decided to leverage these to fix the dark mode flashing issue once and for all (no hack this time!).

Using CSS variables in my themes

Originally I had my theme set to an object looking roughly like the following:

Original version of a theme including light and dark mode colors

1
const theme = {
2
light: {
3
background: #F8F8F9,
4
body: #161617,
5
},
6
dark: {
7
background: #161617,
8
body: #FFFFFF,
9
},
10
};

The cool thing I've learned recently is that it's possible to convert the hardcoded hex values to use CSS Custom Properties in a theme object that is passed to the Emotion Theme Provider.

The first thing to do add these CSS variables in a Emotion Global component:

Emotion global component with CSS Custom properties

1
import { css, Global } from '@emotion/core';
2
import React from 'react';
3
4
const GlobalStyles = () => (
5
<Global
6
styles={css`
7
.theme-light {
8
--theme-colors-gray: #F8F8F9;
9
--theme-colors-black: #161617;
10
}
11
12
.theme-dark {
13
--theme-colors-black: #161617;
14
--theme-colors-white: #FFFFFF;
15
}
16
`}
17
/>
18
);
19
20
export default GlobalStyles;

Then, replace the hex values in the themes with the corresponding CSS variable names:

Updated version of the theme object using CSS Custom Properties

1
const theme = {
2
light: {
3
background: var(--theme-colors-gray, #F8F8F9),
4
body: var(--theme-colors-black, #161617),
5
},
6
dark: {
7
background: var(--theme-colors-black, #161617),
8
body: var(--theme-colors-white, #FFFFFF),
9
},
10
};

Everything should remain pretty much the same, we've simply moved some hex values around and put them in CSS variables under their respective CSS class mode theme-light and theme-dark. Now let's see how this can be leveraged with some good old inline Javascript in a HTML script tag.

Injecting a script

Server rendered websites like Gatbsy let us customize the html.js file. This gives us the possibility to inject a script that will set the proper theme based on the value present in local storage.

If not already available in the src folder the html.js can be copied from the .cache folder of your Gatsby project:

1
cp .cache/default-html.js src/html.js

Note: you can read more about custom html.js in the Gatsby documentation

For NextJS users:

I'll try to take some time to investigate and update this post with a solution for NextJS.

Shout out to @aquaductape for writing a follow up implementation to fix this same issue on NextJS projects. You can check out the code here!

The following will have to be added to this file:

Javascript script that reads the local storage item with the key 'mode' to load the proper theme

1
(function() {
2
try {
3
var mode = localStorage.getItem('mode');
4
var supportDarkMode =
5
window.matchMedia('(prefers-color-scheme: dark)').matches === true;
6
if (!mode && supportDarkMode)
7
document.body.classList.add('theme-dark');
8
if (!mode) return;
9
document.body.classList.add('theme-' + mode);
10
} catch (e) {}
11
})();

This script does the following:

  1. It looks for a local storage item with a key named mode
  2. It looks for the prefers-color-scheme CSS media query, here we look whether its set to dark, which translates to the user loading the website having a system using dark mode.
  3. If there's no mode set in local storage but the user's system uses dark mode, we add a class theme-dark do the body of the main document.
  4. If there's simply no mode set in local storage we don't do anything, which will end up loading the default theme of our UI
  5. Otherwise, we add the class associated with the mode set in local storage to the body of the document

We can add the script to the html.js file inside the <body> tag as follows:

html.js file featuring our custom script

1
...
2
<body {...props.bodyAttributes}>
3
<script
4
key="maximeheckel-theme"
5
dangerouslySetInnerHTML={{
6
__html: `(function() {
7
try {
8
var mode = localStorage.getItem('mode');
9
var supportDarkMode =
10
window.matchMedia('(prefers-color-scheme: dark)').matches === true;
11
if (!mode && supportDarkMode)
12
document.body.classList.add('theme-dark');
13
if (!mode) return;
14
document.body.classList.add('theme-' + mode);
15
} catch (e) {}
16
})();`,
17
}}
18
/>
19
{props.preBodyComponents}
20
<div
21
key={`body`}
22
id="___gatsby"
23
dangerouslySetInnerHTML={{ __html: props.body }}
24
/>
25
{props.postBodyComponents}
26
</body>
27
...

Updating the toggle function

There's one last update to be done: updating the toggle light/dark mode function. We need to add a few lines of code to make sure we add or remove the appropriate CSS class from the body tag, otherwise the colors of our themes will be a bit messed up πŸ˜….

In the example featured in the first blog post this is what the function looked like:

Original function to toggle between light and dark mode

1
const toggle = () => {
2
const dark = !themeState.dark;
3
localStorage.setItem('dark', JSON.stringify(dark));
4
setThemeState({ ...themeState, dark });
5
};

And this is what we need to add to make it work properly again:

Updated function to toggle between light and dark mode

1
const toggle = () => {
2
const dark = !themeState.dark;
3
if(dark) {
4
document.body.classList.remove('theme-light');
5
document.body.classList.add('theme-dark');
6
} else {
7
document.body.classList.remove('theme-dark');
8
document.body.classList.add('theme-light');
9
}
10
localStorage.setItem('dark', JSON.stringify(dark));
11
setThemeState({ ...themeState, dark });
12
};

Result

By adding the code featured in the previous parts, we allow the Javascript related to getting the proper theme to be executed before we start rendering the React code. The appropriate class name to the body tag is going to be set immediately which will allow out CSS variables to be set to the proper variables. Then, for the brief moment when our "flash" issue previously occurred, the theme being used does not matter, as the colors are solely based on the CSS variables πŸŽ‰! This is what makes the flash disappear under the hood.

Fetching Replies...

Do you have any questions, comments or simply wish to contact me privately? Don’t hesitate to shoot me a DM on Twitter.


Have a wonderful day.
Maxime


Β© 2020 Maxime Heckel β€”β€” Made in SF. Polished in NY.