joelschneider(.com)

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:

  1. Javascript was not enabled in the browser
  2. 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: