John Polacek

Chicago Web Developer

Senior Software Engineer at VEG

Shipping open source on Github

Follow me at @johnpolacek

Adding Dark Mode to a Next.js Site

Originally published on 8/21/2020

I am a light mode guy. Dark text on a white background has always seemed more readable to me, so I thought of Dark Mode as a web dev fad. Then I read about the accessibility benefits of dark mode for people who want to reduce eye strain in a low light environment, or who have photosensitive conditions.

Recently Gatsby relaunched its website, and they temporarily got rid of dark mode. Twitter was not happy. “AAAARGH! My EYESSS!” was the general sentiment. So yeah, dark mode is for real and I wanted to add support for it on my site.

The most helpful post I found was The Quest for the Perfect Dark Mode. The key aspect being saving the user’s preference, and avoid the flicker of light mode.

I use Theme UI for styling on all my projects, and they have a nice built-in implementation for color modes in a theme object.

Theme.js

export default {
colors: {
text: "#374047",
background: "#fff",
lite: "#eee",
gray: "#aeb3c0",
modes: {
dark: {
text: "#fff",
background: "#000",
lite: "#333",
gray: "#666",
},
},
},
...

Then we can add a control to change the color mode.

ThemeToggle.js

import { useEffect, useState } from "react"
import { Box, useColorMode } from "theme-ui"
import Button from "./Button"
const ThemeToggle = (props) => {
const [colorMode, setColorMode] = useColorMode()
const [opacity, setOpacity] = useState(0)
useEffect(() => {
// fade in animation
setOpacity(1)
}, [])
return (
<Box
sx={{
p: 3,
position: "absolute",
top: 0,
right: 0,
opacity,
transition: "opacity .25s ease-in-out",
}}
>
<Button
sx={{ bg: "gray", py: 1, px: 2, fontSize: 0 }}
onClick={(e) => {
setColorMode(colorMode === "default" ? "dark" : "default")
}}
>
switch to {colorMode === "default" ? "dark" : "light"} mode
</Button>
</Box>
)
}
export default ThemeToggle

This is all we need to do to get dark mode basically working. The only problem, as mentioned is above, is you will see a brief flash of light mode as the page rehydrates. With a slightly modified of the code from this article, we can avoid that flash by adding a script that runs before the page is rendered by the browser to temporarily inject some inline styles before our ThemeToggle component takes over.

Layout.js

import { useEffect } from "react"
import theme from "./Theme"
import { Box } from "theme-ui"
import PropTypes from "prop-types"
import Head from "./Head"
import Style from "./Style"
import ThemeToggle from "../ui/ThemeToggle"
import Header from "../ui/Header"
// inject inline styles on the body before the page is rendered to avoid the flash of light if we are in dark mode
let codeToRunOnClient = false
if (theme.colors.modes && theme.colors.modes.length !== 0) {
codeToRunOnClient = `
(function() {
const theme = ${JSON.stringify(theme)}
let mode = localStorage.getItem("theme-ui-color-mode")
if (!mode) {
const mql = window.matchMedia('(prefers-color-scheme: dark)')
if (typeof mql.matches === 'boolean' && mql.matches) {
mode = "dark"
}
}
if (mode && typeof theme.colors.modes === "object" && typeof theme.colors.modes[mode] === "object") {
const root = document.documentElement
Object.keys(theme.colors.modes[mode]).forEach((colorName) => {
document.body.style.setProperty("--theme-ui-colors-"+colorName, "var(--theme-ui-colors-primary,"+theme.colors.modes[mode][colorName]+")")
})
}
})()`
}
const Layout = (props) => {
useEffect(() => {
// the theme styles will be applied by theme ui after hydration, so remove the inline style we injected on page load
document.body.removeAttribute("style")
}, [])
return (
<>
<Head {...props} />
{
codeToRunOnClient &&
<script dangerouslySetInnerHTML={{ __html: codeToRunOnClient }} />
}
<Box>
{
typeof theme.colors.modes === "object" &&
<ThemeToggle />
}
<Header />
...

See the full code from this example on Github.