Implementing dark mode in React
Table of contents
In this post, you’ll see 4 methods you can use to add dark mode in a React application. You will also see their limitations and some of React’s limitations. If I were to start a new project, I would use only the last 2 methods or, even better, a library like Theme UI. Nevertheless, in my 100% unbiased opinion, I think it’s an interesting read.
Ideal solution
The ideal solution for implementing “dark mode” in a React application satisfies the following requirements:
- The app supports SSR. If the app runs only on the client-side, then all solutions work without a problem. Here I use a static/prerendered app with Gatsby.
- The app remembers the selected mode, so you’ll want to save it in local storage.
- The app uses React’s Context API to implement dark mode. You may already be using something like styled-components themes not only for colors, but for implementing a design system, or for creating component primitives with styled-system and rebass. As a result, you may want the styling to be in one place, and you don’t want to switch between styled-components, CSS, or inline styling.
Spoiler: None of the solutions checks all the boxes above. Let’s take them one by one to see why this happens. We’ll start with the context.
Dark mode with context (no local storage)
In the first solution, you’ll use context, but you won’t save the theme in the local storage. To implement the context, I’m using a blog post by Kent C. Dodds called How to use react context effectively. (See also How to optimize your context value). The truth is that I’m not applying the performance optimizations from the linked posts because they are not relevant in our case—more about that in a bit.
Create a new file theme-context.jsx
and place it in the src/context
folder. The file looks like this:
import React, {
useState,
useContext,
useCallback,
createContext,
} from "react";
import { ThemeProvider as BaseThemeProvider } from "styled-components";
import { lightTheme, darkTheme } from "../themes";
const ThemeContext = createContext();
const ThemeProvider = ({ children }) => {
const [themeString, setThemeString] = useState("light");
const themeObject = themeString === "dark" ? darkTheme : lightTheme;
return (
<ThemeContext.Provider value={{ themeString, setThemeString }}>
<BaseThemeProvider theme={themeObject}>
{children}
</BaseThemeProvider>
</ThemeContext.Provider>
);
};
function useTheme() {
const context = useContext(ThemeContext);
if (!context)
throw new Error("useTheme must be used within a ThemeProvider");
const { themeString, setThemeString } = context;
const toggleTheme = useCallback(() => {
if (themeString === "light") setThemeString("dark");
else if (themeString === "dark") setThemeString("light");
}, [themeString]);
return {
theme: themeString,
toggleTheme,
};
}
export { ThemeProvider, useTheme };
Let’s try to break down what’s going on in this file. In short, we’re creating a new context for the theme that we can use inside our components with a hook. More specifically:
- We create a new component that stores the current theme in the state. The current theme is a string that can be
"light"
or"dark"
. We call the componentThemeProvider
but think of it as a regular React component that has some state and renders some children. - We use the theme from the state as the
value
for a new context (ThemeContext
). We wrap thechildren
of the previous component with theThemeContext.Provider
(See how to use context in React docs). - In the same component, we use the theme provider from
styled-components
(BaseThemeProvider
). We wrap thechildren
of the component with theBaseThemeProvider
and synchronize the two themes when the first changes. We synchronize the two themes with thethemeObject
variable which is also thevalue
for thestyled-components
context. - We’ll wrap Gatsby’s root element with the
ThemeProvider
component (in a bit) ingatsby-browser.js
andgatsby-ssr.js
files. - Finally, we create a hook that uses the new context. Inside that hook, we show an error if someone tries to use the context outside of a provider (see the previous step). We also memoize the
toggleTheme
function with auseCallback
hook, but, in this case, it’s not that important because we won’t pass that function as a prop to a component. You can remove it if you want. To use the theme context in a component, you only have to use the hook.
You might be wondering why are we creating a new context for the current theme, instead of using the context from styled-components
. We do that because we want inside the context value
the function that toggles the theme, something that the context from styled-components
doesn’t seem to offer.
Let’s now talk about the performance optimizations we skipped—you can safely skip this paragraph. The value
we pass to the theme context is an object. We pass it with value={{ themeString, setThemeString }}
. This means that we are creating and passing a different object in each render. The components that use the context render when the value of the context changes. In a hypothetical scenario where a parent of the ThemeProvider
component renders, the ThemeProvider
component should also render. Because the value of the context is different in every render, that means that the components that use the context should also render. These would be some unnecessary renders because the theme didn’t change. For this reason, many people memoize the context value
with the useMemo
hook to ensure that it doesn’t change when the theme is the same. Additionally, they wrap the components that use the theme with memo
that prevents re-renders when the props are the same. See this codesandbox from Kent C. Dodds that illustrates that. In our case, we’re not wrapping the ThemeProvider
component in a parent component that renders all the time. The only time the ThemeProvider
renders is when the theme changes, and, in this case, we want the components that use the context to render. That’s why I skipped the performance optimizations.
Next, you want to create the styled-components
theme objects in src/themes/
folder. I said that I want to use the theme for other things, not only for colors but let’s use them only for colors to keep the code simple:
const lightTheme = {
bg: "white",
bgDark: "pink",
color: "black",
accent: "blue",
};
const darkTheme = {
bg: "#2c1320",
bgDark: "#15090f",
color: "white",
accent: "#ef86a9",
};
export { lightTheme, darkTheme };
Then, you wrap the Gatsby’s root element in gatsby-browser.js
and gatsby-ssr.js
. The files are identical; just copy-and-paste the same code:
import React from "react";
import "normalize.css";
import "typeface-fira-sans";
import "typeface-merriweather";
import { ThemeProvider } from "./src/context/theme-context";
import GlobalStyle from "./src/components/GlobalStyle";
export const wrapRootElement = ({ element }) => (
<ThemeProvider>
<>
<GlobalStyle />
{element}
</>
</ThemeProvider>
);
You also want a Toggle
component that will switch between light and dark mode. For this reason, I copied the Toggle component from Dan Abramov’s blog, which is a slightly altered Toggle
from react-toggle. In the comments, it says that it has some accessibility improvements. So you can either copy the Toggle.js
, Toggle.css
, and add the sun/moon images from Dan Abramov’s blog, or just use the default toggle from react-toggle
.
You’ll place the toggle somewhere in the Header
component. I’m also using the theme colors, in the Container
component, and the theme hook:
import React from "react";
import { Link } from "gatsby";
import styled from "styled-components";
import PropTypes from "prop-types";
import Toggle from "./Toggle";
import sun from "../images/sun.png";
import moon from "../images/moon.png";
import { useTheme } from "../context/theme-context";
const Container = styled.header`
color: ${({ theme }) => theme.color};
background-color: ${({ theme }) => theme.bgDark};
margin-bottom: 1.45rem;
a {
text-decoration: none;
color: ${({ theme }) => theme.color};
}
`;
const Header = ({ siteTitle }) => {
const { theme, toggleTheme } = useTheme();
return (
<Container>
<div
style={{
margin: `0 auto`,
maxWidth: 960,
padding: `1.45rem 1.0875rem`,
}}
>
<h1 style={{ margin: 0 }}>
<Link to="/">{siteTitle}</Link>
</h1>
<Toggle
defaultChecked={theme === "dark" ? true : false}
onChange={toggleTheme}
icons={{
checked: (
<img
style={{ pointerEvents: "none" }}
width="16"
height="16"
alt="moon"
aria-hidden
src={moon}
/>
),
unchecked: (
<img
style={{ pointerEvents: "none" }}
width="16"
height="16"
alt="sun"
aria-hidden
src={sun}
/>
),
}}
/>
</div>
</Container>
);
};
Header.propTypes = {
siteTitle: PropTypes.string,
};
Header.defaultProps = {
siteTitle: ``,
};
export default Header;
And this is what the app looks like after the changes:
It works fine in both development and production modes, but every time you reload the page, it defaults back to light mode. If your app doesn’t reload much, it might be ok; but you never know how the user will use your app. As a result, this behavior can become annoying really fast. To fix that, you can save the preferred user theme in local storage:
Dark mode with context and local storage
GitHub repo context-local-storage-bug branch
I will use a custom hook to save the theme string in local storage. Alternatively, you can use an NPM package for that. You can use the useLocalStorage
hook by giving a name
string (theme) and the initialValue
(“light”). It returns the value and a function to change it. The hook keeps in the state the value, and when the value changes, it runs a side-effect to save the value in the local storage. It initializes the state with the existing value in local storage or with the initialValue
from the user. It also has some guards for SSR (windowGlobal
variable).
import { useState, useEffect } from "react";
export const useLocalStorage = (name, initialValue) => {
const windowGlobal = typeof window !== "undefined" && window;
const [value, setValue] = useState(() => {
if (windowGlobal) {
const currentValue = windowGlobal.localStorage.getItem(name);
return currentValue ? JSON.parse(currentValue) : initialValue;
}
return initialValue;
});
useEffect(() => {
if (windowGlobal)
windowGlobal.localStorage.setItem(name, JSON.stringify(value));
}, [name, value, windowGlobal]);
return [value, setValue];
};
To use this hook in the ThemeProvider
, you only have to change 2 lines of code:
import React, { useContext, useCallback, createContext } from "react";
import { ThemeProvider as BaseThemeProvider } from "styled-components";
import { useLocalStorage } from "../hooks/useLocalStorage";
import { lightTheme, darkTheme } from "../themes";
const ThemeContext = createContext();
const ThemeProvider = ({ children }) => {
const [themeString, setThemeString] = useLocalStorage(
"theme",
"light"
);
const themeObject = themeString === "dark" ? darkTheme : lightTheme;
return (
<ThemeContext.Provider value={{ themeString, setThemeString }}>
<BaseThemeProvider theme={themeObject}>
{children}
</BaseThemeProvider>
</ThemeContext.Provider>
);
};
function useTheme() {
// skipping hook implementation
}
export { ThemeProvider, useTheme };
If you try it in development, it works fine. You can switch the theme by pressing the toggle, and if you reload the page, the app remembers your preference. But if you build it with gatsby build
, and serve it locally with gatsby serve
, you’ll see the following:
If you choose the dark mode, the theme is getting stored correctly in local storage, but if you reload the page you see the light mode. Also, the Toggle
component is now broken. It needs some extra clicks to change the theme after reloading the page (if you had dark mode selected). If you log in the console the themes and context, you’ll see that everything is fine; the dark mode is selected as expected. So, why is this happening?
This happens because of how React’s hydrate method treats differences between client and server. During build time, we apply the default light theme, so we add to the DOM elements the styled-components
classes for the light theme. When we go to the client and try to hydrate the markup, React sees that we now have a different theme from local storage, so we want to apply different classes for the dark mode. React doesn’t do that for attributes. Only for text mainly because of timestamps differences.
You can see that if you create a post context with 3 posts, and store them the same way you store the theme in local storage. After you save them in local storage, you can comment one post out to create a difference between the server (3 posts) and the client (2 posts). You’ll see that React will hydrate the text correctly, as it’s shown in the following video:
In this case, you can force an extra render when the component mounts to update the classes. If you do that, this is what the app looks like in production:
GitHub repo context-local-storage (correct)
It works now, but you momentarily see a flash of unstyled content. This happens because you go quickly from light to dark mode. It’s quite noticeable because the color differences are big. You can tone it down if you add a CSS transition in the items that change color, but you’ll have to do this for every item. Also, if your app takes some time to load, or the users access your app from a slow network, they will be stuck for a while with the incorrect theme until the app becomes interactive.
It seems that you can’t have a perfect solution if you only use context. In the following method, you’ll run some code before React to manipulate the DOM and change the colors with CSS variables:
Dan Abramov’s solution with CSS
In this section, I will only give a brief overview of this method. If you want the full code, please check the CSS branch on the GitHub repository
You saw earlier that you can’t rely on context due to the way React’s hydrate method treats differences between client and server. So you’ll have to do something else. You’ll want to run your JavaScript code that will initialize the dark mode in a script
tag (before React loads). You’ll place that script
right after the opening body
tag because you want the body
element to exist, but you don’t want the browser to render something on the screen and see that flash again. This works because inline scripts block rendering. In a Gatsby application, one of the ways you can add a script tag is by overriding the default html.js file.
Inside that script, you declare an IIFE where you do the following:
- You get the saved theme from local storage and save the correct one if necessary.
- You perform a matchMedia query for a preferred color scheme. If the user has a preferred color scheme, you aggregate the saved and the preferred, giving priority to the saved.
- You add a class to the body element for the preferred mode (dark, light). You declare your colors with CSS custom properties under
body.light
andbody.dark
(meaning that the colors will change when the class of the body changes). - You attach to the window object the current theme and some methods that you’ll use inside your
header
component later to change the theme.
import React from "react";
import PropTypes from "prop-types";
export default class HTML extends React.Component {
render() {
return (
<html {...this.props.htmlAttributes}>
<head>
<meta charSet="utf-8" />
<meta httpEquiv="x-ua-compatible" content="ie=edge" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
{this.props.headComponents}
</head>
<body {...this.props.bodyAttributes} className="light">
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
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'));
})();
`,
}}
/>
{this.props.preBodyComponents}
<div
key={`body`}
id="___gatsby"
dangerouslySetInnerHTML={{ __html: this.props.body }}
/>
{this.props.postBodyComponents}
</body>
</html>
);
}
}
HTML.propTypes = {
htmlAttributes: PropTypes.object,
headComponents: PropTypes.array,
bodyAttributes: PropTypes.object,
preBodyComponents: PropTypes.array,
body: PropTypes.string,
postBodyComponents: PropTypes.array,
};
This is the code outside of the template string with comments that explain what it does:
(function () {
// This is a callback that gets called by setTheme.
// You’ll assign a setState function to this callback to
// update the React state later inside the header component.
window.__onThemeChange = function () {};
// An inner function that changes the mode but
// doesn’t save in local storage. You won’t use this.
function setTheme(newTheme) {
// This is used inside the header component
// to set the initial state. It’s also used to keep track
// of the mode when the header component mounts/unmounts.
window.__theme = newTheme;
preferredTheme = newTheme;
document.body.className = newTheme;
window.__onThemeChange(newTheme);
}
// Get the saved theme from local storage
var preferredTheme;
try {
preferredTheme = localStorage.getItem("theme");
} catch (err) {}
// The function that changes the mode and saves
// to local storage. You’ll use that inside the header
// component.
window.__setPreferredTheme = function (newTheme) {
setTheme(newTheme);
try {
localStorage.setItem("theme", newTheme);
} catch (err) {}
};
var darkQuery = window.matchMedia("(prefers-color-scheme: dark)");
// When the user changes the preferred color scheme,
// call the window.__setPreferredTheme method.
darkQuery.addListener(function (e) {
window.__setPreferredTheme(e.matches ? "dark" : "light");
});
// Set the theme for the first time by aggregating the
// saved theme (preferred theme variable) and the color
// scheme query. Don't forget the parenthesis, they are
// important
setTheme(preferredTheme || (darkQuery.matches ? "dark" : "light"));
})();
Next, go to the Header
component (because that’s where the Toggle
component is) and do the following:
- Get the correct theme from
window.__theme
in an effect that runs only on mount. - When the user clicks the
Toggle
to change the theme, you call thewindow.__setPreferredTheme()
method. This method saves the theme inlocalStorage
, and updates the body class and the React state. - The
window.__onThemeChange()
is a callback that gets called bywindow.setPreferredTheme()
and updates the React state in this case.
import React, { useState, useEffect } from "react";
// skipped
const Container = styled.header`
color: var(--color);
background-color: var(--bgDark);
margin-bottom: 1.45rem;
a {
text-decoration: none;
color: var(--color);
}
`;
const Header = ({ siteTitle }) => {
const [theme, setTheme] = useState(null);
useEffect(() => {
setTheme(window.__theme);
window.__onThemeChange = () => setTheme(window.__theme);
}, []);
return (
<Container>
<div
style={{
margin: `0 auto`,
maxWidth: 960,
padding: `1.45rem 1.0875rem`,
}}
>
<h1 style={{ margin: 0 }}>
<Link to="/">{siteTitle}</Link>
</h1>
{theme ? (
<Toggle
checked={theme === "dark"}
onChange={(e) =>
window.__setPreferredTheme(
e.target.checked ? "dark" : "light"
)
}
/>
) : (
<div style={{ height: "28px" }} />
)}
</div>
</Container>
);
};
// skipped
Because you don’t want to render the Toggle with the wrong state on mount, you render a placeholder div
element if the theme is null
. The theme is null
on mount both in development and production, which is a good thing because you don’t want differences between the development and production. An alternative is to use the useLayoutEffect
instead of useEffect
that will run the effect before React renders the component with the wrong state on screen. This is not a good practice, though, because it delays browser paints. So pretend I didn’t mention it and stick with the first option.
Now, you can use CSS variables for the colors instead of getting them from the styled-components
theme. I define those variables in a GlobalStyle.jsx
component in this example, but you can import a regular CSS file if you want:
import { createGlobalStyle } from "styled-components";
const GlobalStyle = createGlobalStyle`
// skipped
a {
color: var(--accent);
}
body {
background-color: var(--bg);
}
body.light {
--bg: white;
--bgDark: pink;
--color: black;
--accent: blue;
}
body.dark {
-webkit-font-smoothing: antialiased;
--bg: #2c1320;
--bgDark: #15090f;
--color: white;
--accent: #ef86a9;
}
`;
export default GlobalStyle;
If you build the application, this is how it looks. P.S. Not exactly, in the following video I render the Toggle
in the wrong state on mount. You can see that when I change the theme to dark mode and refresh the page. Don’t worry though, the code above doesn’t do that.
By the way, in this example, I’m still wrapping the root element with a ThemeProvider
from styled-components
. I just don’t use the theme for the colors. See an out-of-the-box dark mode solution with Theme UI if you don’t like that. I should also note that CSS variables (or CSS custom properties) do not work on Internet Explorer.
A refactor of the CSS solution
GitHub repo css-refactor branch
When I first wrote this article more than a year ago, I treated Dan’s solution as a black box without explaining it much which is something I didn’t like. I made some edits in the previous section, but I also want to show you a refactor of this method.
The gist of this method is that you want to run the code that switches the mode in a script. In other words, you take the control from React and give it to an inline script. You do that because React doesn’t handle this use case well. In the next snippet, you can see the script code with syntax highlighting, outside of a string prop:
(function () {
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"));
})();
I find it a bit complicated, so, in the next snippet, you can find a more straight-forward (?) version with comments that explain what happens in each step:
// I use regular functions and var instead of
// const to support older browsers (not IE).
// I also give names to the functions for
// easier debugging.
(function initializeTheme() {
// 1. Get the existing theme from local storage
// and save it to window.__theme.
try {
window.__theme = localStorage.getItem("theme");
} catch (err) {
console.log("Couldn’t get the theme from local storage.");
}
// 2. Inside React, you “subscribe” to theme updates by
// overwriting the following callback.
window.__onThemeChangeCallback = function () {};
// 3. The function that changes the theme and
// calls the callback.
window.__setTheme = function setTheme(newTheme) {
// You’ll use that variable to set the initial
// React state. It's also used to store the
// correct mode when the component mounts
// and unmounts.
window.__theme = newTheme;
// Change the class, call the callback, and
// save the theme in local storage.
document.body.className = newTheme;
window.__onThemeChangeCallback(newTheme);
try {
localStorage.setItem("theme", newTheme);
} catch (err) {
console.log("Couldn’t save the theme in local storage.");
}
};
// 4. A color scheme listener that calls the setTheme
// method.
var darkQuery = window.matchMedia("(prefers-color-scheme: dark)");
darkQuery.addListener(function darkQueryListener(e) {
window.__setTheme(e.matches ? "dark" : "light");
});
// 5. Set the theme for the first time according to
// the saved theme and the color scheme query.
window.__setTheme(
window.__theme || (darkQuery.matches ? "dark" : "light")
);
})();
A problem with both of those snippets is that you can’t use the code inside the Toggle for more than one component. You can’t extract it to a useDarkMode
hook for example because every time you assign a callback to onThemeChangeCallback
, inside a React component, you overwrite the previous component’s callback. As a result, their state will get out of sync. If you want to use the theme only inside a Toggle
component, you don’t have a problem. You can try to solve this by using the theme in a single context component, but you may face the same problems with hydration all over again. So instead of overwriting a single callback, you can keep track of a callback array, and then iterate through it inside the script and call them one-by-one. You can also overengineer the problem with an observer pattern.
(function initializeTheme() {
try {
window.__theme = localStorage.getItem("theme");
} catch (err) {
console.log("Couldn’t get the theme from local storage.");
}
var callbacks = [];
window.__addCallback = function (cb) {
callbacks.push(cb);
};
window.__removeCallback = function (cb) {
callbacks = callbacks.filter(function (callback) {
return callback !== cb;
});
};
window.__setTheme = function setTheme(newTheme) {
window.__theme = newTheme;
document.body.className = newTheme;
callbacks.forEach(function (cb) {
cb();
});
try {
localStorage.setItem("theme", newTheme);
} catch (err) {
console.log("Couldn’t save the theme in local storage.");
}
};
var darkQuery = window.matchMedia("(prefers-color-scheme: dark)");
darkQuery.addListener(function darkQueryListener(e) {
window.__setTheme(e.matches ? "dark" : "light");
});
window.__setTheme(
window.__theme || (darkQuery.matches ? "dark" : "light")
);
})();
This how to use the code above inside the Header
component.
useEffect(() => {
setTheme(window.__theme);
const callback = () => setTheme(window.__theme);
window.__addCallback(callback);
return () => {
window.__removeCallback(callback);
};
}, []);
<Toggle
checked={theme === "dark"}
onChange={(e) =>
window.__setTheme(e.target.checked ? "dark" : "light")
}
/>
I use the last solution in this blog, and, as far as I know, it works. The only difference is that I don’t use a Layout
component that unmounts on page change. Instead, I wrap Gatsby’s pageElement
with the Layout
.
Links
- How to use react context effectively
- Always use memo your context value
- React hydrate styles:
- Dan Abramov’s dark mode implementation:
- Night Mode with Mix Blend Mode: Difference
- Other dark mode solutions. They kind of fall into the previous 3 categories.
Other things to read
Popular
- Reveal animations on scroll with react-spring
- Gatsby background image example
- Extremely fast loading with Gatsby and self-hosted fonts