Adding Dark Mode to a Next.js Site
Originally published on 8/21/2020I 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 animationsetOpacity(1)}, [])return (<Boxsx={{p: 3,position: "absolute",top: 0,right: 0,opacity,transition: "opacity .25s ease-in-out",}}><Buttonsx={{ 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 modelet codeToRunOnClient = falseif (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.documentElementObject.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 loaddocument.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.