@MaximeHeckel

Fixing the dark mode flash issue on server rendered websites

April 16, 2020 / 7 min read

Last Updated: April 16, 2020

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.

<figcaption>Gif showcasing the dark mode flash issue on this blog.</figcaption>

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:

The core of the issue: I was rendering a &lt;div/&gt; when loading the website and reading the localStorage to set the proper theme (since it's async). This stopped gatsby from going further during the SSR build step and hence not generating the pages (with meta tags) of my blog

(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

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) document.body.classList.add('theme-dark');
7
if (!mode) return;
8
document.body.classList.add('theme-' + mode);
9
} catch (e) {}
10
})();

This script does the following:

  1. ArrowAn icon representing an arrow
    It looks for a local storage item with a key named mode
  2. ArrowAn icon representing an arrow
    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. ArrowAn icon representing an arrow
    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. ArrowAn icon representing an arrow
    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. ArrowAn icon representing an arrow
    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 key="maximeheckel-theme" dangerouslySetInnerHTML={{ __html:
4
`(function() { try { var mode = localStorage.getItem('mode'); var
5
supportDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches
6
=== true; if (!mode && supportDarkMode)
7
document.body.classList.add('theme-dark'); if (!mode) return;
8
document.body.classList.add('theme-' + mode); } catch (e) {} })();`, }} />
9
{props.preBodyComponents}
10
<div
11
key="{`body`}"
12
id="___gatsby"
13
dangerouslySetInnerHTML="{{"
14
__html:
15
props.body
16
}}
17
/>
18
{props.postBodyComponents}
19
</body>
20
...

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.

Liked this article? Share it with a friend on Bluesky or Twitter or support me to take on more ambitious projects to write about. Have a question, feedback or simply wish to contact me privately? Shoot me a DM and I'll do my best to get back to you.

Have a wonderful day.

– Maxime

Bringing a proper solution to dark mode flashing without an ugly hack.