Fixing the dark mode flash issue on server rendered websites
April 16, 2020 / 7 min read
Last Updated: April 16, 2020This 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.
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"
1if (!themeState.hasThemeLoaded) {2/*3If the theme is not yet loaded we don't want to render4this is just a workaround to avoid having the app rendering5in light mode by default and then switch to dark mode while6getting the theme state from localStorage7*/8return <div />;9}10const 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 <div/> 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
1const theme = {2light: {3background: #F8F8F9,4body: #161617,5},6dark: {7background: #161617,8body: #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
1import { css, Global } from '@emotion/core';2import React from 'react';34const GlobalStyles = () => (5<Global6styles={css`7.theme-light {8--theme-colors-gray: #f8f8f9;9--theme-colors-black: #161617;10}1112.theme-dark {13--theme-colors-black: #161617;14--theme-colors-white: #ffffff;15}16`}17/>18);1920export 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
1const theme = {2light: {3background: var(--theme-colors-gray, #F8F8F9),4body: var(--theme-colors-black, #161617),5},6dark: {7background: var(--theme-colors-black, #161617),8body: 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:
1cp .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 () {2try {3var mode = localStorage.getItem('mode');4var supportDarkMode =5window.matchMedia('(prefers-color-scheme: dark)').matches === true;6if (!mode && supportDarkMode) document.body.classList.add('theme-dark');7if (!mode) return;8document.body.classList.add('theme-' + mode);9} catch (e) {}10})();
This script does the following:
- It looks for a local storage item with a key named
mode
- 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. - 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. - 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
- 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'); var5supportDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches6=== true; if (!mode && supportDarkMode)7document.body.classList.add('theme-dark'); if (!mode) return;8document.body.classList.add('theme-' + mode); } catch (e) {} })();`, }} />9{props.preBodyComponents}10<div11key="{`body`}"12id="___gatsby"13dangerouslySetInnerHTML="{{"14__html:15props.body16}}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
1const toggle = () => {2const dark = !themeState.dark;3localStorage.setItem('dark', JSON.stringify(dark));4setThemeState({ ...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
1const toggle = () => {2const dark = !themeState.dark;3if (dark) {4document.body.classList.remove('theme-light');5document.body.classList.add('theme-dark');6} else {7document.body.classList.remove('theme-dark');8document.body.classList.add('theme-light');9}10localStorage.setItem('dark', JSON.stringify(dark));11setThemeState({ ...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 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.