Building this site #5: Dark mode with CSS variables and JavaScript trickery

Implementing dark mode on a site can be tricky, as I've just learned for myself doing it on here. Luckily, assistance is always only a few clicks away: Josh W. Comeau's "Quest for the Perfect Dark Mode" is widely cited and offers an excellent overview of the technical challenges involved; his tutorial is for Gatsby but can be readily applied to a different React-based project. I also liberally consulted Rob Morieson's tutorial, which is more specific to Next.js and emphasizes CSS variables; in the end my implementation is a kind of synthesis of the two.

I won't go into incredible detail here, but simply put, the main technical quandary is that we have to rely on localStorage to persist a chosen theme, while working around the fact that it can't be accessed until after a page is rendered – by which time a theme would already have needed to be chosen! The (unwanted) result is a momentary flash in which a default theme is rendered before whichever one needs to be persisted.

Following the aforementioned tutorials, my dark mode implementation comes three distinct parts: nailing down specific colors by theme with CSS variables, injecting a script into our pages that prioritizes accessing localStorage to obtain the persisted theme, and finally creating a ThemeProvider component to supply the theme globally to the site.

Assigning variable colors

Needless to say, it's essential to get organized about which elements need to change color depending on the theme, and which elements exactly. I prefer Rob's approach, which is to to have all of them in a global CSS file – in our case, globals.css – rather than a JavaScript object, and specifying dark and light colors to each variable using data attributes. I also declare a --transition variable with an initial value of 0ms (for reasons that will become clear later) at the root level. That looks something like this:

:root {
  --transition: 0ms;
}

body,
body[data-theme="light"] {
  --color-bg: white;
  --color-bg-secondary: #e8e8e8;
  --color-text: black;
  --color-accent: #345995;
  --color-accent-secondary: #252525;
  --color-accent-muted: #252525;
  --color-muted: lightgray;
}

body[data-theme="dark"] {
  --color-bg: #252525;
  --color-bg-secondary: #3a3a3a;
  --color-text: #daddd8;
  --color-accent: #98b63e;
  --color-accent-secondary: #c9db7a;
  --color-accent-muted: #daddd8;
  --color-muted: #3a3a3a;
}

The rest of our CSS can then simply reference the above variables wherever the colors need to change according to the current theme, like so:

html,
body {
  padding: 0;
  margin: 0;
  font-family: "Open Sans", sans-serif;
  background: var(--color-bg);
  color: var(--color-text);
  transition: var(--transition);
}

a:link,
a:hover,
a:visited,
a:active {
  color: var(--color-accent);
  font-weight: 600;
  transition: var(--transition);
}

a:hover {
  text-decoration: none;
  color: var(--color-accent-secondary);
}

/* and so on */

Setting an initial theme

With all of the above set up, in JavaScript we can simply reference document.body.dataset.theme and assign to it a value of either "light" or "dark" to toggle between themes. But where and when to do that? Remember that we want to respect a user's preferred theme – either the theme last chosen, or according to their system settings – but have to wait until window is available to access that information through localStorage or media queries.

The answer is a little script at the head of our HTML document that will execute and access the data we need before anything else in the page has a chance to render. In Next.js, the place to do this is in a custom pages/_document.js file – something like this:

import Document, { Html, Head, Main, NextScript } from "next/document"

const initialThemeScript = `
  document.body.dataset.theme = (function() {
    const theme = window.localStorage.getItem("theme");
    if (theme) return theme;
    const query = window.matchMedia("(prefers-color-scheme: dark)");
    return query.matches ? "dark" : "light";
  })();
`

export default class MyDocument extends Document {
  render() {
    return (
      <Html>
        <Head />
        <body>
          <script dangerouslySetInnerHTML={{ __html: initialThemeScript }} />
          <Main />
          <NextScript />
        </body>
      </Html>
    )
  }
}

The trick here is that the script must be written as a string, here assigned to the variable initialThemeScript and inserted into our HTML, since it is meant to run in the browser. The script looks into localStorage to see if any theme has previously been set; if it finds nothing, it makes a media query to see if the system is set to dark mode, and defaults to light otherwise. Finally we assign the returned value to document.body.dataset.theme, which will set the proper colors to the site through CSS.

In short, we manage to assign an initial theme attribute to our CSS before we even see anything on the page, avoiding the dreaded "flash". The cost is that it slows down the site marginally; but I am a mere nobody on the internet and no one will notice.

Toggling between themes

The third piece to this whole implementation is being able to toggle between dark and light modes at will, and then supplying the theme globally to whichever components need it. I follow Josh's example and go with a "Theme Provider" approach using React context. This would be unnecessary were I using CSS variables exclusively, but my site uses react-bootstrap components, which, in many cases can be set to different colors just by assigning a different className. I prefer to stick with this feature, rather than mess around with overriding Bootstrap's CSS – though it goes to show one of the disadvantages of relying on a CSS framework.

At any rate, it's not a bad excuse to take advantage of React context. All of that is handled by a ThemeProvider component, which does the work of accessing and reassigning document.body.dataset.theme. We expose a function, toggleTheme, which, when called by any component, sets the theme globally to the opposite value.

import { createContext, useContext, useState, useEffect } from "react"

const ThemeContext = createContext()

export default function ThemeProvider({ children }) {
  const [theme, setTheme] = useState(document.body.dataset.theme);

  useEffect(() => {
    document.documentElement.style.setProperty("--transition", "0ms")
  }, [theme])

  const toggleTheme = () => {
    const newTheme = theme === "light" ? "dark" : "light"
    document.documentElement.style.setProperty("--transition", "250ms")
    document.body.dataset.theme = newTheme
    window.localStorage.setItem("theme", newTheme)
    setTheme(newTheme)
  }

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  )
}

export const useTheme = () => useContext(ThemeContext)

Furthermore, whenever the theme is toggled, we reference document.documentElement.style and change the --transition variable, whose default value is 0ms to 250ms. When that's done, a useEffect fires and changes the value back to 0ms. This is so we get a nice transition effect only when toggling between themes, and not when setting the theme initially upon loading a page.

Anyhow, ThemeProvider wraps around the entire app, so any component that needs a reference to the current theme, or to be able to change it (such as a switch), can get what it needs by using useTheme. Furthermore, since ThemeProvider makes a reference to document, which is not available on the server side, it has to be imported dynamically. Next.js makes this simple:

import "bootstrap/dist/css/bootstrap.min.css"
import "../styles/globals.css"
import dynamic from "next/dynamic"
import NavBar from "../components/NavBar"
import Footer from "../components/Footer"

const ThemeProvider = dynamic(
  () => import("../components/ThemeProvider"), 
  { ssr: false }
)

export default function App({ Component, pageProps }) {
  return (
    <ThemeProvider>
      <NavBar />
      <Component {...pageProps} />
      <Footer />      
    </ThemeProvider>
  )
}

A small downside to this is that nothing under ThemeProvider can render with JavaScript disabled in the browser. At some point I may move to a pure CSS variable-based approach and do away with using context altogether, but for now this is not a huge problem. Using a <noscript> tag, I just add a small notice to the document head, right next to our injected <script>, so that anyone viewing the site with JavaScript disabled isn't left in the dark, as it were:

export default class MyDocument extends Document {
  ...
  <noscript>
    <div className="dead-center">
      <h1>Please enable JavaScript! 😭</h1>
    </div>
  </noscript>
  ...
}

And from here, the only thing left is our switch. Using useTheme directly, the component is able to access the current theme, as well as call toggleTheme on click. It also changes appearance according to the current theme. This component goes on the far right end of my navbar.

import { Nav } from "react-bootstrap"
import { useTheme } from "./ThemeProvider"

const iconClasses = { 
  light: "bi bi-moon-fill nav-icon", 
  dark: "bi bi-sun-fill nav-icon",
}

export default function ThemeSwitch() {
  const { theme, toggleTheme } = useTheme()
  
  return (<Nav.Link
    className={theme && iconClasses[theme]}
    onClick={() => toggleTheme()} 
  />)
}

And there we go: with a little finagling in the right places, we are now able to switch beautifully between dark and light modes, like many respectable modern websites. Furthermore, the chosen theme persists between pages and sessions free of hiccoughs.