Dark Mode
Sep 5, 2020
One of the features I really wanted to explore when I started builing my blog was adding a dark mode. My basic requirements were:
- It should default to the user's preference according to their operating system settings
- It should remember the user's selected preference
- It should not flicker on first load
- The end user should be able to toggle between themes
- The site should be usable without javascript
I read through and tried out some good guides from Maxime Heckel and Josh Comeau on how to implement this. Both of these guides produced working solutions and helped lead me down the right path but ultimately I didn't really like defining my theme in javascript.
Maxime used a theme.js
file with an object for each theme:
export const themeLight = {
background: "gray",
body: "black",
}
export const themeDark = {
background: "black",
body: "white",
}
Josh used a constants.js
file with a COLORS
object:
export const COLORS = {
text: {
light: "hsl(0deg, 0%, 10%)", // white
dark: "hsl(0deg, 0%, 100%)", // near-black
},
background: {
light: "hsl(0deg, 0%, 100%)", // white
dark: "hsl(250deg, 70%, 7%)", // navy navy blue
},
}
I decided to check out how Dan Abramov had implement dark mode on his overreacted.io blog instead. Instead of defining his theme values in JS, he includes them in his basic CSS:
body.light {
--bg: #ffffff;
--bg-secondary: rgb(249, 250, 251);
--header: var(--pink);
}
body.dark {
--bg: #282c35;
--bg-secondary: rgb(54, 60, 72);
--header: #ffffff;
}
Afterwards, I did find a Gatsby plugin that also took inspiration from Dan but I had already written my own implementation by that point.
Adding a default theme
I decided the light theme would be my default. The default theme needed to be used in two cases:
- Javascript was not enabled in the browser
- The browser/operating system does not provide a preference
Like Dan, I decided to set up my CSS variables on the body element for the classes light
and dark
.
body.dark {
--color-text: #f4e9bf;
--color-background: #252c34;
--color-primary: #2d7638;
}
body.light {
--color-text: #252c34;
--color-background: #fffbeb;
--color-primary: #95bb7e;
}
In order to add the default light
class to the <body>
element, I customized the gatsby html.js
file: cp .cache/default-html.js src/html.js
. Within the html.js
file I update it to:
<body {...props.bodyAttributes} className="light">
This will ensure gatsby creates all pages with the light
class on the <body>
.
Detecting the user's stored or OS theme perference
In order to avoid the dreaded default theme flash, I needed to add a script to the <body>
before the actual content. Josh's article covers this issue and solution in pretty good detail but essentially we want to block on a script that sets the correct class on the body. Josh uses the setPreBodyComponents
function within the gatsby-ssr.js
file which works well. However, since I am
already editing the html.js
file I just made the changes there.
First, I needed a function to set the theme class on the <body>
element:
window.__onThemeChange = function () {}
function setTheme(newTheme) {
window.__theme = newTheme
preferredTheme = newTheme
document.body.className = newTheme
window.__onThemeChange(newTheme)
}
I additionally added a window.__onThemeChange()
callback we can use within React later to be informed of changes to the theme. Next, I needed to a way to check for a stored user preference or OS preference:
var preferredTheme
try {
preferredTheme = localStorage.getItem("theme")
} catch (err) {}
window.__setPreferredTheme = function (newTheme) {
setTheme(newTheme)
try {
localStorage.setItem("theme", newTheme)
} catch (err) {}
}
var darkQuery = window.matchMedia("(prefers-color-scheme: dark)")
darkQuery.addListener(function (e) {
window.__setPreferredTheme(e.matches ? "dark" : "light")
})
I needed to included this function in an IIFE within a script at the top of the body. In order to so, I wrapped all of the logic in a function and called String(themeScript)
to get the function as a string.
function themeScript() {
window.__onThemeChange = function () {}
function setTheme(newTheme) {
window.__theme = newTheme
preferredTheme = newTheme
document.body.className = newTheme
window.__onThemeChange(newTheme)
}
var preferredTheme
try {
preferredTheme = localStorage.getItem("theme")
} catch (err) {}
window.__setPreferredTheme = function (newTheme) {
setTheme(newTheme)
try {
localStorage.setItem("theme", newTheme)
} catch (err) {}
}
var darkQuery = window.matchMedia("(prefers-color-scheme: dark)")
darkQuery.addListener(function (e) {
window.__setPreferredTheme(e.matches ? "dark" : "light")
})
setTheme(preferredTheme || (darkQuery.matches ? "dark" : "light"))
}
const themeScriptContent = `(${String(themeScript)})()`
Once I had a string version of my function, I added it to the top of the body with:
<script dangerouslySetInnerHTML={{ __html: themeScriptContent }} />
Accessing and updating the theme from react
I created a ThemeContext
in React to store and manipulate the theme.
import React, { createContext, useEffect, useState } from "react"
export const ThemeContext = createContext()
export const ThemeProvider = ({ children }) => {
const [theme, setTheme] = useState(null)
useEffect(() => {
setTheme(window.__theme)
window.__onThemeChange = () => {
setTheme(window.__theme)
}
}, [theme])
return (
<ThemeContext.Provider value={{ theme }}>{children}</ThemeContext.Provider>
)
}
Once the ThemeProvider
is mounted we check window.__theme
for the current theme and define a window.__onThemeChange()
function to update our internal state any time the theme changes. Our theme context just exposes the current theme
defaulting to null
. I wrapped everything in my layout with <ThemeProvider>
so that I could access and update the theme anywhere.
This is where my code differs from Dan's a little bit. Dan stores and manipulates the theme state directly within his <Layout>
component. This works totally fine and my <ThemeProvider>
is probably overkill. However, having a ThemeContext
does allow me to include multiple theme toggles on my page while keeping all their state in sync (which is mostly useful for this particular blog post).
Creating a theme toggle
The last step is to create a theme toggle to allow users to manually change their theme. All of our logic is already written so all we need to do is use the ThemeContext
, check theme
and call window.__setPreferredTheme()
. I used the react-switch library because I really had no interest in creating a toggle switch right now.
import React, { useContext } from "react"
import Switch from "react-switch"
import { ThemeContext } from "./ThemeContext"
const ThemeToggle = () => {
const { theme } = useContext(ThemeContext)
if (theme === null) return <div style="height: 28px; width: 56px;"></div>
return (
<Switch
onChange={checked => {
window.__setPreferredTheme(checked ? "dark" : "light")
}}
checked={theme === "dark"}
/>
)
}
I did included a placeholder div
when theme === null
. This ensures the toggle doens't show the wrong value before the theme
is known and that there are no shifts in content when the toggle appears. And finally, all of that work gets you this: